diff --git a/app/build.gradle b/app/build.gradle index d230b3cea..20ec7b7b3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,11 +1,7 @@ plugins { id 'com.android.application' id 'com.google.devtools.ksp' - // to download blocklists for the headless variant - id "de.undercouch.download" version "5.3.0" id 'kotlin-android' - id 'com.google.gms.google-services' - id 'com.google.firebase.crashlytics' } def keystorePropertiesFile = rootProject.file("keystore.properties") @@ -48,14 +44,14 @@ try { } android { - compileSdk 34 + compileSdk 35 // https://developer.android.com/studio/build/configure-app-module namespace 'com.celzero.bravedns' defaultConfig { applicationId "com.celzero.bravedns" minSdkVersion 23 - targetSdkVersion 34 + targetSdkVersion 35 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } @@ -76,6 +72,47 @@ android { } } + // https://developer.android.com/studio/build/configure-apk-splits + splits.abi { + println('Create separate apks') + // generates multiple APKs based on the ABIs you define + enable true + reset() + // comma-separated list of ABIs that you want Gradle to generate APKs for + include 'x86', 'armeabi', 'armeabi-v7a', 'arm64-v8a', 'x86_64' + // generates a universal APK in addition to per-ABI APKs + universalApk true + } + // version codes for each ABI variant + project.ext.versionCodes = [ + 'armeabi' : 1, + 'armeabi-v7a': 2, + 'arm64-v8a' : 3, + 'x86' : 8, + 'x86_64' : 9 + ] + android.applicationVariants.configureEach { variant -> + println("variant name: ${variant.name}") + variant.outputs.configureEach { output -> + // def abi = output.filters.find { it.filterType.name == "ABI" }?.identifier + def abi = variant.outputs.first().getFilter(com.android.build.OutputFile.ABI) + def baseAbiVersionCode = project.ext.versionCodes.get(abi) + println("base version code: $baseAbiVersionCode") + if (abi != null) { + println("variant name: ${variant.name}, abi: $abi") + // assign different version code for each output + // eg for arm64-v8a, version code will be 30000000 + variant.versionCode + def v = baseAbiVersionCode * 10000000 + variant.versionCode + // API 'ApkVariantOutput.getVersionCodeOverride()' is obsolete and has been replaced + // with 'VariantOutput.versionCode()' + output.versionCodeOverride = v + println("version code override: $v") + } else { + println("no ABI filter applied for variant: ${variant.name}") + } + } + } + buildTypes { release { // modified as part of #352, now webview is removed from app, flipping back @@ -115,15 +152,6 @@ android { } } - variantFilter { variant -> - def releaseChannel = variant.getFlavors().get(0).name - def releaseType = variant.getFlavors().get(1).name - - if (releaseType == 'headless' && releaseChannel != 'fdroid') { - variant.setIgnore(true) - } - } - flavorDimensions = ["releaseChannel", "releaseType"] productFlavors { play { @@ -135,17 +163,11 @@ android { website { dimension "releaseChannel" } - headless { - dimension "releaseType" - minSdkVersion 31 - // stackoverflow.com/a/60560178 - // buildConfigField 'string', 'timestamp', '1662384683026' - } full { dimension "releaseType" // getPackageInfo().versionCode not returning the correct value (in prod builds) when // value is set in AndroidManifest.xml so setting it here - // for buildtype alpha, versionCode is set in env overriding gradle.properties + // for build type alpha, versionCode is set in env overriding gradle.properties versionCode = getVersionCode() versionName = gitVersion vectorDrawables.useSupportLibrary = true @@ -154,16 +176,6 @@ android { lint { abortOnError false } - - tasks.configureEach { task -> - if (task.name.toLowerCase().contains('headless')) { - task.dependsOn downloadBlocklists - if (task.name.endsWith("BuildConfig")) { - task.enabled false - } - } - } - } configurations { @@ -173,9 +185,8 @@ configurations { } dependencies { - androidTestImplementation 'androidx.test:rules:1.5.0' def room_version = "2.6.1" - def paging_version = "3.2.1" + def paging_version = "3.3.6" implementation 'com.google.guava:guava:32.1.1-android' @@ -183,20 +194,20 @@ dependencies { // included to fix issues with Android 6 support, issue#563 coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") - fullImplementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.21' - fullImplementation 'androidx.appcompat:appcompat:1.6.1' - fullImplementation 'androidx.core:core-ktx:1.12.0' + fullImplementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.1.0' + fullImplementation 'androidx.appcompat:appcompat:1.7.1' + fullImplementation 'androidx.core:core-ktx:1.16.0' implementation 'androidx.preference:preference-ktx:1.2.1' - fullImplementation 'androidx.constraintlayout:constraintlayout:2.1.4' + fullImplementation 'androidx.constraintlayout:constraintlayout:2.2.1' fullImplementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' - fullImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3' - fullImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' + fullImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1' + fullImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1' // LiveData - implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.7.0' + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.9.1' - implementation 'com.google.code.gson:gson:2.10.1' + implementation 'com.google.code.gson:gson:2.11.0' implementation "androidx.room:room-runtime:$room_version" ksp "androidx.room:room-compiler:$room_version" @@ -204,14 +215,14 @@ dependencies { implementation "androidx.room:room-paging:$room_version" fullImplementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' - fullImplementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0' - fullImplementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0' + fullImplementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.1' + fullImplementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.9.1' // Pagers Views implementation "androidx.paging:paging-runtime-ktx:$paging_version" - fullImplementation 'androidx.fragment:fragment-ktx:1.6.2' - implementation 'com.google.android.material:material:1.11.0' - fullImplementation 'androidx.viewpager2:viewpager2:1.0.0' + fullImplementation 'androidx.fragment:fragment-ktx:1.8.8' + implementation 'com.google.android.material:material:1.12.0' + fullImplementation 'androidx.viewpager2:viewpager2:1.1.0' fullImplementation 'com.squareup.okhttp3:okhttp:4.12.0' fullImplementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0' @@ -249,14 +260,14 @@ dependencies { fullImplementation 'com.github.kirich1409:viewbindingpropertydelegate-noreflection:1.5.9' // from: https://jitpack.io/#celzero/firestack - download 'com.github.celzero:firestack:ee0a5ac71f@aar' - websiteImplementation 'com.github.celzero:firestack:ee0a5ac71f@aar' - fdroidImplementation 'com.github.celzero:firestack:ee0a5ac71f@aar' - // debug symbols for crashlytics - playImplementation 'com.github.celzero:firestack:ee0a5ac71f:debug@aar' + download 'com.github.celzero:firestack:72a648fd02@aar' + websiteImplementation 'com.github.celzero:firestack:72a648fd02@aar' + fdroidImplementation 'com.github.celzero:firestack:72a648fd02@aar' + // debug symbols + playImplementation 'com.github.celzero:firestack:72a648fd02:debug@aar' // Work manager - implementation('androidx.work:work-runtime-ktx:2.9.0') { + implementation('androidx.work:work-runtime-ktx:2.10.1') { modules { module("com.google.guava:listenablefuture") { replacedBy("com.google.guava:guava", "listenablefuture is part of guava") @@ -265,18 +276,19 @@ dependencies { } // for handling IP addresses and subnets, both IPv4 and IPv6 - // https://seancfoley.github.io/IPAddress/ipaddress.html + // seancfoley.github.io/IPAddress/ipaddress.html download 'com.github.seancfoley:ipaddress:5.4.0' implementation 'com.github.seancfoley:ipaddress:5.4.0' testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' + androidTestImplementation 'androidx.test:rules:1.6.1' leakCanaryImplementation 'com.squareup.leakcanary:leakcanary-android:2.14' - fullImplementation 'androidx.navigation:navigation-fragment-ktx:2.7.7' - fullImplementation 'androidx.navigation:navigation-ui-ktx:2.7.7' + fullImplementation 'androidx.navigation:navigation-fragment-ktx:2.9.0' + fullImplementation 'androidx.navigation:navigation-ui-ktx:2.9.0' fullImplementation 'androidx.biometric:biometric:1.1.0' @@ -284,47 +296,20 @@ dependencies { playImplementation 'com.google.android.play:app-update-ktx:2.1.0' // for encrypting wireguard configuration files - implementation("androidx.security:security-crypto:1.1.0-alpha06") - implementation("androidx.security:security-app-authenticator:1.0.0-alpha03") - androidTestImplementation("androidx.security:security-app-authenticator:1.0.0-alpha03") + implementation("androidx.security:security-crypto:1.1.0-beta01") + implementation("androidx.security:security-app-authenticator:1.0.0-rc01") + androidTestImplementation("androidx.security:security-app-authenticator:1.0.0-rc01") // barcode scanner for wireguard fullImplementation 'com.journeyapps:zxing-android-embedded:4.3.0' - - // only using firebase crashlytics experimentally for stability tracking, only in play variant - // not in fdroid or website - playImplementation 'com.google.firebase:firebase-crashlytics:19.0.0' - playImplementation 'com.google.firebase:firebase-crashlytics-ndk:19.0.0' -} - -// github.com/michel-kraemer/gradle-download-task/issues/131#issuecomment-464476903 -tasks.register('downloadBlocklists', Download) { - // def assetsDir = new File(projectDir, 'src/main/assets' - def assetsDir = android.sourceSets.headless.assets.srcDirs[0] - // the filenames are ignored by dl, but acts as a hint for the output - // filename for the download-plugin, which does not respect the - // content-disposition http header, but rather guesses dest file names - // from the final segment of url's path - // github.com/michel-kraemer/gradle-download-task/blob/64d1ce32/src/main/java/de/undercouch/gradle/tasks/download/DownloadAction.java#L731 - def sources = [ - 'https://dl.rethinkdns.com/blocklists/filetag.json', - 'https://dl.rethinkdns.com/basicconfig/basicconfig.json', - 'https://dl.rethinkdns.com/rank/rd.txt', - 'https://dl.rethinkdns.com/trie/td.txt', - ] - src(sources) - dest assetsDir - // download files only if last-modified of the local file is less than - // the last-modified http header returned by the server - onlyIfModified true - // or if etag mismatches - useETag true - // overwrite older files as determined by last-modified, always - overwrite true -} - -tasks.register('downloadDependencies', Copy) { - dependsOn downloadBlocklists - from configurations.download - into "libs" + fullImplementation 'com.simplecityapps:recyclerview-fastscroll:2.0.1' + + // for confetti animation + fullImplementation 'nl.dionsegijn:konfetti-xml:2.0.4' + // for in-app purchases + playImplementation 'com.android.billingclient:billing:7.1.1' + websiteImplementation 'com.android.billingclient:billing:7.1.1' + // for stripe payment gateway + websiteImplementation 'com.stripe:stripe-android:21.15.1' + fdroidImplementation 'com.stripe:stripe-android:21.15.1' } diff --git a/app/src/androidTest/java/com/celzero/bravedns/AntiCensorshipActivityTest.kt b/app/src/androidTest/java/com/celzero/bravedns/AntiCensorshipActivityTest.kt new file mode 100644 index 000000000..3f006834e --- /dev/null +++ b/app/src/androidTest/java/com/celzero/bravedns/AntiCensorshipActivityTest.kt @@ -0,0 +1,97 @@ +/* + * Copyright 2024 RethinkDNS and its authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.celzero.bravedns + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.ext.junit.rules.ActivityScenarioRule +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.ui.activity.AntiCensorshipActivity +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +@RunWith(AndroidJUnit4::class) +class AntiCensorshipActivityTest: KoinComponent { + + @get:Rule + val activityRule = ActivityScenarioRule(AntiCensorshipActivity::class.java) + + private val persistentState by inject() + + @Test + fun shouldSetNeverSplitModeWhenRadioNeverSplitChecked() { + activityRule.scenario.onActivity { activity -> + activity.b.acRadioNeverSplit.isChecked = true + } + assertEquals(AntiCensorshipActivity.DialStrategies.NEVER_SPLIT.mode, persistentState.dialStrategy) + } + + @Test + fun shouldSetSplitAutoModeWhenRadioSplitAutoChecked() { + activityRule.scenario.onActivity { activity -> + activity.b.acRadioSplitAuto.isChecked = true + } + assertEquals(AntiCensorshipActivity.DialStrategies.SPLIT_AUTO.mode, persistentState.dialStrategy) + } + + @Test + fun shouldSetRetryWithSplitModeWhenRadioRetryWithSplitChecked() { + activityRule.scenario.onActivity { activity -> + activity.b.acRadioRetryWithSplit.isChecked = true + } + assertEquals(AntiCensorshipActivity.RetryStrategies.RETRY_WITH_SPLIT.mode, persistentState.retryStrategy) + } + + @Test + fun shouldSetRetryNeverModeWhenRadioNeverRetryChecked() { + activityRule.scenario.onActivity { activity -> + activity.b.acRadioNeverRetry.isChecked = true + } + assertEquals(AntiCensorshipActivity.RetryStrategies.RETRY_NEVER.mode, persistentState.retryStrategy) + } + + @Test + fun shouldSetRetryAfterSplitModeWhenRadioRetryAfterSplitChecked() { + activityRule.scenario.onActivity { activity -> + activity.b.acRadioRetryAfterSplit.isChecked = true + } + assertEquals(AntiCensorshipActivity.RetryStrategies.RETRY_AFTER_SPLIT.mode, persistentState.retryStrategy) + } + + @Test + fun shouldUncheckOtherRadiosWhenNeverSplitChecked() { + activityRule.scenario.onActivity { activity -> + activity.b.acRadioNeverSplit.isChecked = true + assertEquals(false, activity.b.acRadioSplitAuto.isChecked) + assertEquals(false, activity.b.acRadioSplitTcp.isChecked) + assertEquals(false, activity.b.acRadioSplitTls.isChecked) + assertEquals(false, activity.b.acRadioDesync.isChecked) + } + } + + @Test + fun shouldUncheckOtherRetryRadiosWhenRetryWithSplitChecked() { + activityRule.scenario.onActivity { activity -> + activity.b.acRadioRetryWithSplit.isChecked = true + assertEquals(false, activity.b.acRadioNeverRetry.isChecked) + assertEquals(false, activity.b.acRadioRetryAfterSplit.isChecked) + } + } +} + diff --git a/app/src/fdroid/java/com/celzero/bravedns/iab/SubscriptionCheckWorker.kt b/app/src/fdroid/java/com/celzero/bravedns/iab/SubscriptionCheckWorker.kt new file mode 100644 index 000000000..8b014b582 --- /dev/null +++ b/app/src/fdroid/java/com/celzero/bravedns/iab/SubscriptionCheckWorker.kt @@ -0,0 +1,98 @@ +package com.celzero.bravedns.iab + +import Logger +import Logger.LOG_IAB +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.android.billingclient.api.BillingClient.ProductType +import com.android.billingclient.api.Purchase +import com.celzero.bravedns.iab.InAppBillingHandler.isListenerRegistered +import com.celzero.bravedns.service.PersistentState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class SubscriptionCheckWorker( + val context: Context, + workerParams: WorkerParameters +) : CoroutineWorker(context, workerParams), KoinComponent { + + private val persistentState by inject() + private var attempts = 0 + + companion object { + const val WORK_NAME = "SubscriptionCheckWorker" + } + + override suspend fun doWork(): Result { + return withContext(Dispatchers.IO) { + try { + initiate() + // by default, return success + Result.success() + } catch (e: Exception) { + Logger.e(LOG_IAB, "$WORK_NAME; failed: ${e.message}") + Result.retry() + } + } + } + + private fun initiate() { + if (InAppBillingHandler.isBillingClientSetup() && isListenerRegistered(listener)) { + Logger.i(LOG_IAB, "initBilling: billing client already setup") + return + } + if (InAppBillingHandler.isBillingClientSetup() && !isListenerRegistered(listener)) { + InAppBillingHandler.registerListener(listener) + Logger.i(LOG_IAB, "initBilling: billing listener registered") + return + } + InAppBillingHandler.initiate(context, listener) + } + + private fun reinitiate(attempt: Int = 0) { + if (attempt > 3) { + Logger.e(LOG_IAB, "$WORK_NAME; reinitiate failed after 3 attempts") + return + } + // reinitiate the billing client + initiate() + } + + private val listener = object : BillingListener { + override fun onConnectionResult(isSuccess: Boolean, message: String) { + Logger.d(LOG_IAB, "$WORK_NAME; onConnectionResult: isSuccess: $isSuccess, message: $message") + if (!isSuccess) { + Logger.e(LOG_IAB, "$WORK_NAME;Billing connection failed: $message") + reinitiate(attempts++) + return + } + // check for the subscription status after the connection is established + val productType = listOf(ProductType.SUBS) + InAppBillingHandler.fetchPurchases(productType) + } + + override fun purchasesResult(isSuccess: Boolean, purchaseDetailList: List) { + val first = purchaseDetailList.firstOrNull() + if (first == null) { + Logger.d(LOG_IAB, "$WORK_NAME; No purchases found") + persistentState.enableWarp = false + return + } + if (first.productType == ProductType.SUBS) { + Logger.d(LOG_IAB, "$WORK_NAME; Subscription found: ${first.state}") + persistentState.enableWarp = first.state == Purchase.PurchaseState.PURCHASED + } else { + Logger.d(LOG_IAB, "$WORK_NAME; No subscription found") + persistentState.enableWarp = false + } + } + + override fun productResult(isSuccess: Boolean, productList: List) { + Logger.v(LOG_IAB, "$WORK_NAME; productResult: isSuccess: $isSuccess, productList: $productList") + } + } + +} diff --git a/app/src/fdroid/java/com/celzero/bravedns/iab/stripe/PricesAdapter.kt b/app/src/fdroid/java/com/celzero/bravedns/iab/stripe/PricesAdapter.kt new file mode 100644 index 000000000..0d69f6f91 --- /dev/null +++ b/app/src/fdroid/java/com/celzero/bravedns/iab/stripe/PricesAdapter.kt @@ -0,0 +1,40 @@ +package com.celzero.bravedns.iab.stripe + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView + +class PricesAdapter : RecyclerView.Adapter() { + + private var prices: List = emptyList() + + fun submitList(newPrices: List) { + prices = newPrices + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PriceViewHolder { + val view = LayoutInflater.from(parent.context).inflate(android.R.layout.simple_list_item_2, parent, false) + return PriceViewHolder(view) + } + + override fun onBindViewHolder(holder: PriceViewHolder, position: Int) { + val price = prices[position] + holder.bind(price) + } + + override fun getItemCount(): Int = prices.size + + class PriceViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val title: TextView = itemView.findViewById(android.R.id.text1) + private val details: TextView = itemView.findViewById(android.R.id.text2) + + fun bind(price: Price) { + Logger.i("StripeApi","Price: $price, title: ${price.id}, details: ${price.unit_amount}, ${price.currency}, ${price.product}") + title.text = "Product: ${price.product}" + details.text = "Price: ${price.unit_amount / 100.0} ${price.currency.uppercase()}" + } + } +} diff --git a/app/src/fdroid/java/com/celzero/bravedns/iab/stripe/RetrofitInstance.kt b/app/src/fdroid/java/com/celzero/bravedns/iab/stripe/RetrofitInstance.kt new file mode 100644 index 000000000..8c9fb044f --- /dev/null +++ b/app/src/fdroid/java/com/celzero/bravedns/iab/stripe/RetrofitInstance.kt @@ -0,0 +1,47 @@ +package com.celzero.bravedns.iab.stripe + +import com.celzero.bravedns.customdownloader.RetrofitManager +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory + +object RetrofitInstance { + private const val BASE_URL = "https://api.stripe.com/" + + private val client = OkHttpClient.Builder().build() + + val api: StripeApiService by lazy { + RetrofitManager.getStripeBaseBuilder(0) + } + + val paymentApi: StripePaymentService by lazy { + Retrofit.Builder() + .baseUrl(BASE_URL) + .client(client) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(StripePaymentService::class.java) + } + + data class CustomerResponse( + val id: String, + val email: String, + val name: String, + val description: String?, + val created: Long, + val address: Address? + ) + + data class Address( + val city: String?, + val country: String? + ) + + val retrofit = Retrofit.Builder() + .baseUrl("https://api.stripe.com/") + .addConverterFactory(GsonConverterFactory.create()) + .build() + + val stripeCustomerService = retrofit.create(StripeCustomerService::class.java) + +} diff --git a/app/src/fdroid/java/com/celzero/bravedns/iab/stripe/StripeApiService.kt b/app/src/fdroid/java/com/celzero/bravedns/iab/stripe/StripeApiService.kt new file mode 100644 index 000000000..5bd920849 --- /dev/null +++ b/app/src/fdroid/java/com/celzero/bravedns/iab/stripe/StripeApiService.kt @@ -0,0 +1,70 @@ +package com.celzero.bravedns.iab.stripe + +import com.google.gson.annotations.SerializedName +import okhttp3.FormBody +import okhttp3.RequestBody +import retrofit2.Call +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.POST +import retrofit2.http.Query + +data class Price( + val id: String, + val unit_amount: Long, + val currency: String, + val product: String +) + +data class PricesResponse( + val data: List +) + +interface StripeApiService { + @GET("v1/prices") + fun getPrices( + @Header("Authorization") authorization: String, + @Query("limit") limit: Int = 10 + ): Call +} + + +data class PaymentIntentRequest( + val amount: Long, + val currency: String +) + +data class PaymentIntentResponse( + val id: String, + val client_secret: String, + val amount: Long, + val currency: String +) + +data class CustomerCreateParams( + val email: String, + val name: String, + val description: String? = null, + @SerializedName("address[city]") val city: String? = null, + @SerializedName("address[country]") val country: String? = null +) + + +interface StripePaymentService { + @POST("v1/payment_intents") + fun createPaymentIntent( + @Header("Authorization") authorization: String, + @Body formBody: RequestBody + ): Call +} + +interface StripeCustomerService { + @POST("v1/customers") + fun createCustomer( + @Header("Authorization") authorization: String, + @Body params: CustomerCreateParams + ): Call +} + + diff --git a/app/src/fdroid/java/com/celzero/bravedns/iab/stripe/SubscriptionViewModel.kt b/app/src/fdroid/java/com/celzero/bravedns/iab/stripe/SubscriptionViewModel.kt new file mode 100644 index 000000000..ad9175ea8 --- /dev/null +++ b/app/src/fdroid/java/com/celzero/bravedns/iab/stripe/SubscriptionViewModel.kt @@ -0,0 +1,38 @@ +package com.celzero.bravedns.iab.stripe + +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response + +class SubscriptionViewModel : ViewModel() { + + private val _pricesLiveData = MutableLiveData>() + val pricesLiveData: LiveData> get() = _pricesLiveData + + private val productKey = "" + + private val stripeApi = RetrofitInstance.api + private val publishableKey = "" + private val secretKey = "" + + fun fetchPrices() { + stripeApi.getPrices(authorization = secretKey).enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) { + if (response.isSuccessful) { + val prices = response.body()?.data ?: emptyList() + _pricesLiveData.value = prices.filter { it.product == productKey } + } else { + Log.e("StripeAPI", "Error: ${response.errorBody()?.string()}") + } + } + + override fun onFailure(call: Call, t: Throwable) { + Log.e("StripeAPI", "Failure: ${t.message}") + } + }) + } +} diff --git a/app/src/fdroid/java/com/celzero/bravedns/ui/fragment/RethinkPlusFragment.kt b/app/src/fdroid/java/com/celzero/bravedns/ui/fragment/RethinkPlusFragment.kt new file mode 100644 index 000000000..8d3b2801a --- /dev/null +++ b/app/src/fdroid/java/com/celzero/bravedns/ui/fragment/RethinkPlusFragment.kt @@ -0,0 +1,827 @@ +/* + * Copyright 2024 RethinkDNS and its authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.celzero.bravedns.ui.fragment + +import Logger +import Logger.LOG_IAB +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.View +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import backend.Backend +import by.kirich1409.viewbindingdelegate.viewBinding +import com.android.billingclient.api.BillingClient.ProductType +import com.android.billingclient.api.Purchase +import com.celzero.bravedns.R +import com.celzero.bravedns.adapter.GooglePlaySubsAdapter +import com.celzero.bravedns.databinding.FragmentRethinkPlusBinding +import com.celzero.bravedns.iab.InAppBillingHandler +import com.celzero.bravedns.iab.PricingPhase +import com.celzero.bravedns.iab.Result.resultState +import com.celzero.bravedns.iab.stripe.CustomerCreateParams +import com.celzero.bravedns.iab.stripe.PaymentIntentResponse +import com.celzero.bravedns.iab.stripe.PricesAdapter +import com.celzero.bravedns.iab.stripe.RetrofitInstance +import com.celzero.bravedns.iab.stripe.RetrofitInstance.CustomerResponse +import com.celzero.bravedns.iab.stripe.SubscriptionViewModel +import com.celzero.bravedns.rpnproxy.RpnProxyManager +import com.celzero.bravedns.rpnproxy.RpnProxyManager.RPN_AMZ_ID +import com.celzero.bravedns.rpnproxy.RpnProxyManager.WARP_ID +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.service.VpnController +import com.celzero.bravedns.service.WireguardManager +import com.celzero.bravedns.ui.activity.PingTestActivity +import com.celzero.bravedns.ui.activity.TroubleshootActivity +import com.celzero.bravedns.ui.dialog.SubscriptionAnimDialog +import com.celzero.bravedns.util.UIUtils.underline +import com.celzero.bravedns.util.Utilities +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.stripe.android.PaymentConfiguration +import com.stripe.android.paymentsheet.PaymentSheet +import com.stripe.android.paymentsheet.PaymentSheetResult +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.FormBody +import okhttp3.RequestBody +import org.koin.android.ext.android.inject +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response + +class RethinkPlusFragment : Fragment(R.layout.fragment_rethink_plus) { + private val b by viewBinding(FragmentRethinkPlusBinding::bind) + private val persistentState by inject() + private var productId = "" + private var planId = "" + private lateinit var loadingDialog: AlertDialog + private lateinit var errorDialog: AlertDialog + private val stripeViewModel by inject() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + PaymentConfiguration.init(requireContext().applicationContext, "") + paymentSheet = PaymentSheet(this, ::onPaymentSheetResult) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + ensureBillingSetup() + initView() + initObservers() + collectPurchases() + setupClickListeners() + } + + private fun initView() { + // show a loading dialog + showLoadingDialog() + io { + val isRethinkPlusSubscribed = isRethinkPlusSubscribed() // based on persistent state + + if (isRethinkPlusSubscribed) { + uiCtx { handlePlusSubscribed() } + return@io + } + + val works = isRethinkPlusAvailable() + + if (!works.first) { + uiCtx { showRethinkNotAvailableUi(works.second) } + return@io + } + + addWarpSEToTunnel() + + // perform initial checks whether the proxy is working or not + // should we do this if rethink+ is already subscribed? + val isTestOk = isTestOk() + + if (!isTestOk) { + uiCtx { showTestContainerUi() } + return@io + } + + // initiate the product details query + queryProductDetail() + } + } + + private fun isBillingAvailable(): Boolean { + return InAppBillingHandler.isBillingClientSetup() + } + + private fun ensureBillingSetup() { + if (isBillingAvailable()) { + Logger.i(LOG_IAB, "ensureBillingSetup: billing client already setup") + return + } + + InAppBillingHandler.initiate(requireContext(), null) + Logger.i(LOG_IAB, "ensureBillingSetup: billing client initiated") + } + + private suspend fun queryProductDetail() { + ensureBillingSetup() + InAppBillingHandler.queryProductDetailsWithTimeout() + Logger.v(LOG_IAB, "queryProductDetails: initiated") + } + + private fun purchaseStripe() { + createPaymentIntent() + } + + private lateinit var paymentSheet: PaymentSheet + private var clientSecret: String? = null + + private fun createPaymentIntent() { + val stripeApi = RetrofitInstance.paymentApi + val secretKey = "" + + createCustomerIfNeeded(secretKey) + + val formBody: RequestBody = FormBody.Builder() + .add("amount", "100") + .add("currency", "usd") + .add("description", "Test Rethink Plus") + .add("customer", "") + .build() + + stripeApi.createPaymentIntent(secretKey, formBody) + .enqueue(object : Callback { + override fun onResponse( + call: Call, + response: Response + ) { + Logger.i("StripeApi", "onResponse, ${response.body()}") + if (response.isSuccessful) { + clientSecret = response.body()?.client_secret + presentPaymentSheet() + Logger.i("StripeApi", "presenting payment sheet, res success") + } else { + // Handle errors + Logger.i("StripeApi", "onResponse, ${response.errorBody()?.string()}") + } + } + + override fun onFailure(call: Call, t: Throwable) { + // Handle failure + Logger.i("StripeApi", "onFailure, ${t.message}") + } + }) + } + + private fun createCustomerIfNeeded(secretKey: String) { + val stripeCustomerService = RetrofitInstance.stripeCustomerService + + val customerParams = CustomerCreateParams( + email = "customer@example.com", + name = "John Doe", + description = "Test customer", + city = "San Francisco", + country = "US" + ) + + stripeCustomerService.createCustomer(secretKey, customerParams) + .enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) { + if (response.isSuccessful) { + val customerResponse = response.body() + println("Customer Created: $customerResponse") + customerResponse?.let { + // Retrieve the customer ID + val customerId = it.id + println("Customer ID: $customerId") + } ?: run { + println("Response body is null") + } + } else { + println("Error Response: ${response.errorBody()?.string()}") + } + } + + override fun onFailure(call: Call, t: Throwable) { + println("API Call Failed: ${t.message}") + } + }) + } + + private fun presentPaymentSheet() { + val paymentSheetConfiguration = PaymentSheet.Configuration( + merchantDisplayName = "Rethink Plus", + allowsDelayedPaymentMethods = true + ) + Logger.i("StripeApi", "presentWithPaymentIntent, $paymentSheetConfiguration") + paymentSheet.presentWithPaymentIntent(clientSecret!!, paymentSheetConfiguration) + } + + private fun onPaymentSheetResult(paymentSheetResult: PaymentSheetResult) { + when (paymentSheetResult) { + is PaymentSheetResult.Completed -> { + // Payment successful + Logger.e("StripeApi", "Payment successful") + } + + is PaymentSheetResult.Failed -> { + // Handle failure + Logger.e("StripeApi", "Payment failed") + } + + is PaymentSheetResult.Canceled -> { + // Handle cancellation + Logger.e("StripeApi", "Payment cancelled") + } + } + } + + private fun purchaseSubs() { + if (!isBillingAvailable()) { + Logger.e(LOG_IAB, "purchaseSubs: billing client not available") + Utilities.showToastUiCentered(requireContext(), "Billing client not available, please try again later", Toast.LENGTH_LONG) + return + } + // initiate the payment flow + InAppBillingHandler.purchaseSubs(requireActivity(), productId, planId) + Logger.v(LOG_IAB, "purchaseSubs: initiated") + } + + private fun showLoadingDialog() { + val builder = MaterialAlertDialogBuilder(requireContext()) + // show progress dialog + builder.setTitle("Loading") + builder.setMessage("Please wait while we check the availability of Rethink+.") + builder.setCancelable(false) + loadingDialog = builder.create() + loadingDialog.show() + } + + private fun hideLoadingDialog() { + Logger.v(LOG_IAB, "hide loading dialog") + if (this::loadingDialog.isInitialized && loadingDialog.isShowing) { + loadingDialog.dismiss() + } + Logger.v(LOG_IAB, "loading dialog dismissed") + } + + private suspend fun isRethinkPlusAvailable(): Pair { + val warpWorks = RpnProxyManager.isWarpWorking() + Logger.i(LOG_IAB, "warp works: $warpWorks") + return warpWorks + } + + private suspend fun handleWarp(): Boolean { + // see if the warp conf is available, if not create a new one + val cf = RpnProxyManager.getWarpConfig() + if (cf == null) { + return createWarpConfig() + } else { + Logger.i(LOG_IAB, "warp config already exists") + } + return true + } + + private suspend fun handleAmnezia(): Boolean { + // see if the amnezia conf is available, if not create a new one + val cf = RpnProxyManager.getAmneziaConfig() + if (cf == null) { + return createAmneziaConfig() + } else { + Logger.i(LOG_IAB, "amz config already exists") + } + return true + } + + private suspend fun createWarpConfig(): Boolean { + // create a new warp config + val config = RpnProxyManager.getNewWarpConfig(true, WARP_ID, 0) + if (config == null) { + Logger.e(LOG_IAB, "err creating warp config") + showConfigCreationError(getString(R.string.new_warp_error_toast)) + return false + } + return true + } + + private suspend fun createAmneziaConfig(): Boolean { + // create a new amnezia config + val config = RpnProxyManager.getNewAmneziaConfig(RPN_AMZ_ID) + if (config == null) { + Logger.e(LOG_IAB, "err creating amz config") + showConfigCreationError("Error creating Amnezia config") + return false + } + return true + } + + private suspend fun addAmneziaToTunnel() { + if (!handleAmnezia()) { + Logger.e(LOG_IAB, "err handling amz") + return + } + val c = RpnProxyManager.getAmneziaConfig() + val config = RpnProxyManager.getAmneziaConfig().first + if (config == null) { + Logger.e(LOG_IAB, "err adding amz to tunnel") + showConfigCreationError("Error adding amz to tunnel") + return + } + if (c.second) { + Logger.i(LOG_IAB, "amz already active") + return + } + Logger.i(LOG_IAB, "enabling amnezia(amz) config") + RpnProxyManager.enableConfig(config.getId()) + } + + private suspend fun addWarpToTunnel() { + if (!handleWarp()) { + Logger.e(LOG_IAB, "err handling warp") + return + } + val cf = RpnProxyManager.getWarpConfig() + val config = RpnProxyManager.getWarpConfig().first + if (config == null) { + Logger.e(LOG_IAB, "err adding warp to tunnel") + showConfigCreationError(getString(R.string.new_warp_error_toast)) + return + } + if (cf.second) { + Logger.i(LOG_IAB, "warp already active") + return + } + Logger.i(LOG_IAB, "enabling warp config") + RpnProxyManager.enableConfig(config.getId()) + } + + private suspend fun registerSEToTunnel() { + // add the SE to the tunnel + val isRegistered = VpnController.registerSEToTunnel() + if (!isRegistered) { + Logger.e(LOG_IAB, "err registering SE to tunnel") + showConfigCreationError("Error registering SE to tunnel") + } + Logger.i(LOG_IAB, "SE registered to tunnel") + } + + private fun showPaymentContainerUi(purc: List = emptyList()) { + hideLoadingDialog() + hidePlusSubscribedUi() + hideNotAvailableUi() + hideTestLayoutUi() + b.paymentContainer.visibility = View.VISIBLE + b.testPingButton.underline() + // set adapter for the recycler view, create a new adapter in this file + if (Utilities.isFdroidFlavour() || Utilities.isWebsiteFlavour()) { + setStripeAdapter() + } else { + setAdapter(purc) + } + Logger.i(LOG_IAB, "adapter set") + } + + private fun setStripeAdapter() { + val adapter = PricesAdapter() + b.subscriptionPlans.layoutManager = LinearLayoutManager(context) + b.subscriptionPlans.adapter = adapter + + stripeViewModel.pricesLiveData.observe(viewLifecycleOwner) { prices -> + adapter.submitList(prices) + } + + stripeViewModel.fetchPrices() + } + + private fun hidePaymentContainerUi() { + b.paymentContainer.visibility = View.GONE + } + + private fun hidePlusSubscribedUi() { + b.subscribedLayout.visibility = View.GONE + } + + private fun showNotAvailableUi() { + hideLoadingDialog() + hidePlusSubscribedUi() + hidePaymentContainerUi() + hideTestLayoutUi() + b.notAvailableLayout.visibility = View.VISIBLE + } + + private fun hideNotAvailableUi() { + b.notAvailableLayout.visibility = View.GONE + } + + private fun hideTestLayoutUi() { + b.testLayout.visibility = View.GONE + } + + private fun showPlusSubscribedUi() { + hideLoadingDialog() + b.subscribedLayout.visibility = View.VISIBLE + hidePaymentContainerUi() + hideTestLayoutUi() + hideNotAvailableUi() + val res = if (persistentState.enableWarp) "Enable" else "Disable" + b.troubleshoot.text = "Troubleshoot: $res" + b.pausePlus.text = if (persistentState.enableWarp) "Pause Rethink+" else "Resume Rethink+" // for testing purpose + } + + private suspend fun isTestOk(): Boolean { + val warp = VpnController.testWarp() + val amz = VpnController.testAmz() + val proton = VpnController.testProton() + val se = VpnController.testSE() + val x64 = VpnController.testExit64() + Logger.i(LOG_IAB, "test ok?: warp: $warp, amz: $amz, proton: $proton, se: $se, w64: $x64") + + val works = warp || amz || proton || se || x64 + return works + } + + private fun showRethinkNotAvailableUi(msg: String) { + showNotAvailableUi() + val builder = MaterialAlertDialogBuilder(requireContext()) + builder.setTitle("Plus not available") + builder.setMessage("Rethink+ is not available for your device.\nreason: $msg") + builder.setCancelable(false) + builder.setPositiveButton(requireContext().getString(R.string.dns_info_positive)) { dialogInterface, _ -> + dialogInterface.dismiss() + findNavController().navigate(R.id.action_switch_to_homeScreenFragment) + } + builder.create().show() + } + + private fun showTestContainerUi() { + hideLoadingDialog() + b.testLayout.visibility = View.VISIBLE + } + + private fun setAdapter(list: List) { + if (list.isEmpty()) { + Logger.d(LOG_IAB, "pricing phase list is empty/initialized") + return + } + // set the adapter for the recycler view + Logger.i(LOG_IAB, "setting adapter for the recycler view: ${list.size}") + b.subscriptionPlans.setHasFixedSize(true) + val layoutManager = LinearLayoutManager(requireContext()) + b.subscriptionPlans.layoutManager = layoutManager + b.subscriptionPlans.adapter = GooglePlaySubsAdapter(list) + } + + private fun collectPurchases() { + InAppBillingHandler.purchasesLiveData.observe(viewLifecycleOwner) { list -> + Logger.d(LOG_IAB, "collectPurchases: Purchase details: ${list.size}") + if (list.isEmpty()) { + Logger.d(LOG_IAB, "No purchases found") + persistentState.enableWarp = false // rethink+ not subscribed + // initiate the product details query + io { queryProductDetail() } + return@observe + } + + list.forEach { it -> + if (it.state == Purchase.PurchaseState.PURCHASED && it.productType == ProductType.SUBS) { + if (isAdded && isVisible) { + showConfettiEffect() + handlePlusSubscribed() + } + + // add the purchase details to the databased + Logger.d( + LOG_IAB, + "Purchase details: ${it.state}, ${it.productId}, ${it.purchaseToken}, ${it.productTitle}, ${it.purchaseTime}, ${it.productType}, ${it.planId}" + ) + } + } + } + } + + private fun initObservers() { + resultState.observe(viewLifecycleOwner) { i -> + Logger.d(LOG_IAB, "res state: ${i.name}, ${i.message};p? ${i.priority}") + if (i.priority == InAppBillingHandler.Priority.HIGH) { + Logger.e(LOG_IAB, "res failure: ${i.name}, ${i.message}; p? ${i.priority}") + if (isAdded && isVisible && this::errorDialog.isInitialized && !errorDialog.isShowing) { + hideLoadingDialog() + showErrorDialog(i.message) + } + } + b.showStatus.text = i.message + } + + InAppBillingHandler.connectionStateLiveData.observe(viewLifecycleOwner) { i -> + Logger.d(LOG_IAB, "onConnectionResult: isSuccess: ${i.isSuccess}, message: ${i.message}") + if (!i.isSuccess) { + Logger.e(LOG_IAB, "Billing connection failed: ${i.message}") + ui { + if (isAdded && isVisible) { + hideLoadingDialog() + Utilities.showToastUiCentered(requireContext(), i.message, Toast.LENGTH_SHORT) + } + } + return@observe + } + // check for the subscription status after the connection is established + val productType = listOf(ProductType.SUBS) + InAppBillingHandler.fetchPurchases(productType) + } + + InAppBillingHandler.productDetailsLiveData.observe(viewLifecycleOwner) { list -> + Logger.d(LOG_IAB, "product details: ${list.size}") + if (list.isEmpty()) { + Logger.e(LOG_IAB, "product details is empty") + ui { + if (isAdded && isVisible) { + hideLoadingDialog() + Utilities.showToastUiCentered( + requireContext(), + "Error fetching product details", + Toast.LENGTH_SHORT + ) + } + } + return@observe + } + val first = list.first() + productId = first.productId + planId = first.planId + val product = first.pricingDetails + Logger.i(LOG_IAB, "Product details: ${first.productId}, ${first.planId}, ${first.productTitle}, ${first.productType}, ${first.pricingDetails.size}") + if (isAdded && isVisible) { + showPaymentContainerUi(product) + } + } + } + + private fun showErrorDialog(msg: String) { + val builder = MaterialAlertDialogBuilder(requireContext()) + builder.setTitle("Error") + builder.setMessage(msg) + builder.setCancelable(false) + builder.setPositiveButton(requireContext().getString(R.string.dns_info_positive)) { dialogInterface, _ -> + dialogInterface.dismiss() + } + errorDialog = builder.create() + errorDialog.show() + } + + private fun showConfettiEffect() { + SubscriptionAnimDialog().show(childFragmentManager, "SubscriptionAnimDialog") + } + + private fun handlePlusSubscribed() { + showPlusSubscribedUi() + io { addWarpSEToTunnel() } + persistentState.enableWarp = true + } + + private suspend fun addWarpSEToTunnel() { + addWarpToTunnel() + addAmneziaToTunnel() + registerSEToTunnel() + } + + private fun setupClickListeners() { + b.termsAndConditions.setOnClickListener { + b.termsAndConditionsText.visibility = + if (b.termsAndConditionsText.visibility == View.VISIBLE) View.GONE else View.VISIBLE + } + + b.paymentButton.setOnClickListener { + if (Utilities.isWebsiteFlavour() || Utilities.isFdroidFlavour()) { + purchaseStripe() + } else { + purchaseSubs() + } + } + + b.testPingButton.setOnClickListener { + val intent = Intent(requireContext(), PingTestActivity::class.java) + startActivity(intent) + } + + b.testPing.setOnClickListener { + val intent = Intent(requireContext(), PingTestActivity::class.java) + startActivity(intent) + } + + b.manageSubscription.setOnClickListener { + // open the manage subscription page + managePlayStoreSubs() + } + + b.paymentHistory.setOnClickListener { + openBillingHistory() + } + + b.troubleshoot.setOnClickListener { + val intent = Intent(requireContext(), TroubleshootActivity::class.java) + startActivity(intent) + } + + b.refreshWarp.setOnClickListener { + io { + createWarpConfig() + addWarpToTunnel() + } + } + + b.contactSupport.setOnClickListener { + io { isTestOk() } + } + + b.pausePlus.setOnClickListener { + if (persistentState.enableWarp) { + persistentState.enableWarp = false + val warp = WireguardManager.getConfigFilesById(WARP_ID) + if (warp == null) { + Logger.e(LOG_IAB, "err getting warp config") + return@setOnClickListener + } + WireguardManager.disableConfig(warp) + } else { + persistentState.enableWarp = true + io { + addWarpToTunnel() + addAmneziaToTunnel() + registerSEToTunnel() + } + } + b.pausePlus.text = if (persistentState.enableWarp) "Pause Rethink+" else "Resume Rethink+" + } + } + + private fun openBillingHistory() { + try { + val link = InAppBillingHandler.HISTORY_LINK + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link))) + } catch (e: ActivityNotFoundException) { + Utilities.showToastUiCentered( + requireContext(), + "play store not found", + Toast.LENGTH_SHORT + ) + Logger.e(LOG_IAB, "Play store not found", e) + } + } + + private fun managePlayStoreSubs() { + try { + // link for the play store which has placeholders for subscription id and package name + val link = InAppBillingHandler.LINK + // replace $1 with subscription id + // replace $2 with package name + val linkWithSubs = link.replace("\$1", InAppBillingHandler.PRODUCT_ID_TEST) + val linkWithSubsAndPackage = linkWithSubs.replace("\$2", requireContext().packageName) + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(linkWithSubsAndPackage))) + } catch (e: ActivityNotFoundException) { + Utilities.showToastUiCentered( + requireContext(), + "Play store not found", + Toast.LENGTH_SHORT + ) + Logger.e(LOG_IAB, "Play store not found", e) + } + } + + private suspend fun showConfigCreationError(msg: String) { + uiCtx { + if (isAdded && isVisible) { + Utilities.showToastUiCentered( + requireContext(), + msg, + Toast.LENGTH_LONG + ) + } + } + } + + /*private val billingListener = object : BillingListener { + override fun onConnectionResult(isSuccess: Boolean, message: String) { + *//*Logger.d(LOG_IAB, "onConnectionResult: isSuccess: $isSuccess, message: $message") + if (!isSuccess) { + Logger.e(LOG_IAB, "Billing connection failed: $message") + ui { + if (isAdded && isVisible) { + Utilities.showToastUiCentered(requireContext(), message, Toast.LENGTH_SHORT) + } + } + return + } + // check for the subscription status after the connection is established + val productType = listOf(ProductType.SUBS) + InAppBillingHandler.fetchPurchases(productType)*//* + } + + override fun purchasesResult(isSuccess: Boolean, purchaseDetailList: List) { + *//*if (!isSuccess) { + Logger.e(LOG_IAB, "query purchase details failed") + // TODO: should we show a toast here / retry the query? + return + } + + if (purchaseDetailList.isEmpty()) { + Logger.d(LOG_IAB, "No purchases found") + persistentState.enableWarp = false // rethink+ not subscribed + // initiate the product details query + io { queryProductDetail() } + return + } + + Logger.d(LOG_IAB, "purchasesResult: Purchase details: ${purchaseDetailList.size}") + purchaseDetailList.forEach { it -> + if (it.state == Purchase.PurchaseState.PURCHASED && it.productType == ProductType.SUBS) { + io { + uiCtx { + showConfettiEffect() + handlePlusSubscribed() + } + } + + // add the purchase details to the databased + Logger.d( + LOG_IAB, + "Purchase details: ${it.state}, ${it.productId}, ${it.purchaseToken}, ${it.productTitle}, ${it.purchaseTime}, ${it.productType}, ${it.planId}" + ) + } + }*//* + } + + override fun productResult(isSuccess: Boolean, productList: List) { + *//*Logger.d(LOG_IAB, "productResult: Product details: ${productList.size}, $isSuccess") + if (!isSuccess) { + Logger.e(LOG_IAB, "Product details failed") + io { + uiCtx { + if (isAdded && isVisible) { + Utilities.showToastUiCentered( + requireContext(), + "Error fetching product details", + Toast.LENGTH_LONG + ) + } + } + } + return + } + if (persistentState.enableWarp) { + Logger.i(LOG_IAB, "User already subscribed to Rethink+") + return + } + val first = productList.firstOrNull() // use the first product details for now + if (first == null) { + Logger.e(LOG_IAB, "Product details is null") + return + } + productId = first.productId + planId = first.planId + val product = first.pricingDetails + Logger.i(LOG_IAB, "Product details: ${first.productId}, ${first.planId}, ${first.productTitle}, ${first.productType}, ${first.pricingDetails.size}") + io { + uiCtx { + if (isAdded && isVisible) { + showPaymentContainerUi(product) + } + } + }*//* + } + }*/ + + private fun isRethinkPlusSubscribed(): Boolean { + // check whether the user has already subscribed to Rethink+ or not in database + return persistentState.enableWarp // for now + } + + private suspend fun uiCtx(f: suspend () -> Unit) { + withContext(Dispatchers.Main) { f() } + } + + private fun ui(f: () -> Unit) { + lifecycleScope.launch(Dispatchers.Main) { f() } + } + + private fun io(f: suspend () -> Unit) { + lifecycleScope.launch(Dispatchers.IO) { f() } + } +} diff --git a/app/src/fdroid/java/com/celzero/bravedns/util/Logger.kt b/app/src/fdroid/java/com/celzero/bravedns/util/Logger.kt deleted file mode 100644 index fa043e17c..000000000 --- a/app/src/fdroid/java/com/celzero/bravedns/util/Logger.kt +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Copyright 2021 RethinkDNS and its authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import android.util.Log -import com.celzero.bravedns.service.PersistentState -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject - -object Logger : KoinComponent { - private val persistentState by inject() - private var logLevel = persistentState.goLoggerLevel - - const val LOG_TAG_APP_UPDATE = "NonStoreAppUpdater" - const val LOG_TAG_VPN = "VpnLifecycle" - const val LOG_TAG_CONNECTION = "ConnectivityEvents" - const val LOG_TAG_DNS = "DnsManager" - const val LOG_TAG_FIREWALL = "FirewallManager" - const val LOG_BATCH_LOGGER = "BatchLogger" - const val LOG_TAG_APP_DB = "AppDatabase" - const val LOG_TAG_DOWNLOAD = "DownloadManager" - const val LOG_TAG_UI = "ActivityManager" - const val LOG_TAG_SCHEDULER = "JobScheduler" - const val LOG_TAG_BUG_REPORT = "BugReport" - const val LOG_TAG_BACKUP_RESTORE = "BackupRestore" - const val LOG_PROVIDER = "BlocklistProvider" - const val LOG_TAG_PROXY = "ProxyLogs" - const val LOG_QR_CODE = "QrCodeFromFileScanner" - const val LOG_GO_LOGGER = "LibLogger" - - // github.com/celzero/firestack/blob/bce8de917fec5e48a41ed1e96c9d942ee0f7996b/intra/log/logger.go#L76 - enum class LoggerType(val id: Int) { - VERY_VERBOSE(0), - VERBOSE(1), - DEBUG(2), - INFO(3), - WARN(4), - ERROR(5), - STACKTRACE(6), - USR(7), - NONE(8); - - companion object { - fun fromId(id: Int): LoggerType { - return when (id) { - 0 -> VERY_VERBOSE - 1 -> VERBOSE - 2 -> DEBUG - 3 -> INFO - 4 -> WARN - 5 -> ERROR - 6 -> STACKTRACE - 7 -> USR - 8 -> NONE - else -> NONE - } - } - } - - fun stacktrace(): Boolean { - return this == STACKTRACE - } - - fun user(): Boolean { - return this == USR - } - } - - fun vv(tag: String, message: String) { - log(tag, message, LoggerType.VERY_VERBOSE) - } - - fun v(tag: String, message: String) { - log(tag, message, LoggerType.VERBOSE) - } - - fun d(tag: String, message: String) { - log(tag, message, LoggerType.DEBUG) - } - - fun i(tag: String, message: String) { - log(tag, message, LoggerType.INFO) - } - - fun w(tag: String, message: String, e: Exception? = null) { - log(tag, message, LoggerType.WARN, e) - } - - fun e(tag: String, message: String, e: Exception? = null) { - log(tag, message, LoggerType.ERROR, e) - } - - fun crash(tag: String, message: String, e: Exception?= null) { - log(tag, message, LoggerType.ERROR, e) - } - - fun updateConfigLevel(level: Long) { - logLevel = level - } - - fun throwableToException(throwable: Throwable): Exception { - return if (throwable is Exception) { - throwable - } else { - Exception(throwable) - } - } - - private fun log(tag: String, msg: String, type: LoggerType, e: Exception? = null) { - when (type) { - LoggerType.VERY_VERBOSE -> if (logLevel <= LoggerType.VERY_VERBOSE.id) Log.v(tag, msg) - LoggerType.VERBOSE -> if (logLevel <= LoggerType.VERBOSE.id) Log.v(tag, msg) - LoggerType.DEBUG -> if (logLevel <= LoggerType.DEBUG.id) Log.d(tag, msg) - LoggerType.INFO -> if (logLevel <= LoggerType.INFO.id) Log.i(tag, msg) - LoggerType.WARN -> if (logLevel <= LoggerType.WARN.id) Log.w(tag, msg, e) - LoggerType.ERROR -> if (logLevel <= LoggerType.ERROR.id) Log.e(tag, msg, e) - LoggerType.STACKTRACE -> if (logLevel <= LoggerType.ERROR.id) Log.e(tag, msg, e) - LoggerType.USR -> {} // Do nothing - LoggerType.NONE -> {} // Do nothing - } - } -} diff --git a/app/src/full/AndroidManifest.xml b/app/src/full/AndroidManifest.xml index f21fd903e..baa617bc4 100644 --- a/app/src/full/AndroidManifest.xml +++ b/app/src/full/AndroidManifest.xml @@ -22,14 +22,12 @@ + - - - - @@ -46,6 +44,33 @@ + + + + + + + + + + + + + + + @@ -113,8 +138,10 @@ + + + + + + + @@ -152,6 +195,12 @@ android:name=".receiver.NotificationActionReceiver" android:exported="false" android:label="@string/app_name" /> + + + + + ().scheduleDatabaseRefreshJob() get().scheduleDataUsageJob() get().schedulePurgeConnectionsLog() + get().schedulePurgeConsoleLogs() } private fun turnOnStrictMode() { diff --git a/app/src/full/java/com/celzero/bravedns/adapter/AppWiseDomainsAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/AppWiseDomainsAdapter.kt index ce5fe747e..f734f5475 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/AppWiseDomainsAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/AppWiseDomainsAdapter.kt @@ -22,6 +22,7 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.LifecycleOwner import androidx.paging.PagingDataAdapter @@ -31,16 +32,22 @@ import com.celzero.bravedns.R import com.celzero.bravedns.data.AppConnection import com.celzero.bravedns.databinding.ListItemAppDomainDetailsBinding import com.celzero.bravedns.service.DomainRulesManager +import com.celzero.bravedns.service.FirewallManager +import com.celzero.bravedns.service.VpnController import com.celzero.bravedns.ui.bottomsheet.AppDomainRulesBottomSheet import com.celzero.bravedns.util.UIUtils import com.celzero.bravedns.util.Utilities import com.celzero.bravedns.util.Utilities.removeBeginningTrailingCommas +import com.celzero.bravedns.util.Utilities.showToastUiCentered +import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlin.math.log2 class AppWiseDomainsAdapter( val context: Context, val lifecycleOwner: LifecycleOwner, - val uid: Int + val uid: Int, + val isRethink: Boolean, + val isActiveConn: Boolean = false ) : PagingDataAdapter( DIFF_CALLBACK @@ -64,17 +71,16 @@ class AppWiseDomainsAdapter( newConnection: AppConnection ) = oldConnection == newConnection } + + private const val TAG = "AppWiseDomainsAdapter" } private lateinit var adapter: AppWiseDomainsAdapter - // ui component to update/toggle the buttons - data class ToggleBtnUi(val txtColor: Int, val bgColor: Int) - override fun onCreateViewHolder( parent: ViewGroup, viewType: Int - ): AppWiseDomainsAdapter.ConnectionDetailsViewHolder { + ): ConnectionDetailsViewHolder { val itemBinding = ListItemAppDomainDetailsBinding.inflate( LayoutInflater.from(parent.context), @@ -86,7 +92,7 @@ class AppWiseDomainsAdapter( } override fun onBindViewHolder( - holder: AppWiseDomainsAdapter.ConnectionDetailsViewHolder, + holder: ConnectionDetailsViewHolder, position: Int ) { val appConnection: AppConnection = getItem(position) ?: return @@ -120,6 +126,22 @@ class AppWiseDomainsAdapter( } private fun displayTransactionDetails(conn: AppConnection) { + // handle active connections specially, no need to show progress bar, + // asn info will be added in the appOrDnsName field + if (isActiveConn) { + b.progress.visibility = View.GONE + b.acdCount.text = conn.count.toString() + b.acdDomain.text = beautifyIpString(conn.ipAddress) + if (conn.appOrDnsName.isNullOrEmpty()) { + b.acdIpAddress.text = "" + } else { + b.acdIpAddress.text = conn.appOrDnsName + } + b.acdFlag.visibility = View.VISIBLE + b.acdFlag.text = conn.flag + return + } + b.acdCount.text = conn.count.toString() b.acdDomain.text = conn.appOrDnsName b.acdFlag.text = conn.flag @@ -134,17 +156,66 @@ class AppWiseDomainsAdapter( private fun setupClickListeners(conn: AppConnection) { b.acdContainer.setOnClickListener { + if (isActiveConn) { + showCloseConnectionDialog(conn) + return@setOnClickListener + } // open bottom sheet to apply domain/ip rules openBottomSheet(conn) } } + private fun showCloseConnectionDialog(appConn: AppConnection) { + if (context !is AppCompatActivity) { + Logger.w(LOG_TAG_UI, "$TAG err showing close connection dialog") + return + } + + if (isRethink) { + Logger.i(LOG_TAG_UI, "$TAG rethink connection - no close connection dialog") + return + } + Logger.v(LOG_TAG_UI, "$TAG show close connection dialog for uid: $uid") + val dialog = MaterialAlertDialogBuilder(context) + .setTitle(context.getString(R.string.close_conns_dialog_title)) + .setMessage(context.getString(R.string.close_conns_dialog_desc, appConn.ipAddress)) + .setPositiveButton(R.string.lbl_proceed) { _, _ -> + // close the connection + VpnController.closeConnectionsByUidDomain(appConn.uid, appConn.ipAddress) + Logger.i( + LOG_TAG_UI, + "$TAG closed connection for uid: ${appConn.uid}, domain: ${appConn.appOrDnsName}" + ) + showToastUiCentered( + context, + context.getString(R.string.config_add_success_toast), + Toast.LENGTH_LONG + ) + } + .setNegativeButton(R.string.lbl_cancel, null) + .create() + dialog.setCancelable(true) + dialog.setCanceledOnTouchOutside(true) + dialog.show() + } + private fun openBottomSheet(appConn: AppConnection) { if (context !is AppCompatActivity) { - Logger.w(LOG_TAG_UI, "Error opening the app conn bottom sheet") + Logger.w(LOG_TAG_UI, "$TAG err opening the app conn bottom sheet") + return + } + + if (isRethink) { + Logger.i(LOG_TAG_UI, "$TAG rethink connection - no bottom sheet") + return + } + + if (isActiveConn) { + Logger.i(LOG_TAG_UI, "$TAG active connection - no bottom sheet") return } + Logger.v(LOG_TAG_UI, "$TAG open bottom sheet for uid: $uid, ip: ${appConn.ipAddress}, domain: ${appConn.appOrDnsName}") val bottomSheetFragment = AppDomainRulesBottomSheet() // Fix: free-form window crash // all BottomSheetDialogFragment classes created must have a public, no-arg constructor. diff --git a/app/src/full/java/com/celzero/bravedns/adapter/AppWiseIpsAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/AppWiseIpsAdapter.kt index 95f3c86d8..dc4a0d659 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/AppWiseIpsAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/AppWiseIpsAdapter.kt @@ -37,7 +37,7 @@ import com.celzero.bravedns.util.Utilities import com.celzero.bravedns.util.Utilities.removeBeginningTrailingCommas import kotlin.math.log2 -class AppWiseIpsAdapter(val context: Context, val lifecycleOwner: LifecycleOwner, val uid: Int) : +class AppWiseIpsAdapter(val context: Context, val lifecycleOwner: LifecycleOwner, val uid: Int, val isRethink: Boolean, val isAsn: Boolean = false) : PagingDataAdapter(DIFF_CALLBACK), AppIpRulesBottomSheet.OnBottomSheetDialogFragmentDismiss { @@ -51,24 +51,19 @@ class AppWiseIpsAdapter(val context: Context, val lifecycleOwner: LifecycleOwner override fun areContentsTheSame(old: AppConnection, new: AppConnection) = old == new } + private const val TAG = "AppWiseIpsAdapter" } private lateinit var adapter: AppWiseIpsAdapter - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int - ): AppWiseIpsAdapter.ConnectionDetailsViewHolder { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConnectionDetailsViewHolder { val itemBinding = ListItemAppIpDetailsBinding.inflate(LayoutInflater.from(parent.context), parent, false) adapter = this return ConnectionDetailsViewHolder(itemBinding) } - override fun onBindViewHolder( - holder: AppWiseIpsAdapter.ConnectionDetailsViewHolder, - position: Int - ) { + override fun onBindViewHolder(holder: ConnectionDetailsViewHolder, position: Int) { val appConnection: AppConnection = getItem(position) ?: return // updates the app-wise connections from network log to AppInfo screen holder.update(appConnection) @@ -108,10 +103,15 @@ class AppWiseIpsAdapter(val context: Context, val lifecycleOwner: LifecycleOwner private fun openBottomSheet(conn: AppConnection) { if (context !is AppCompatActivity) { - Logger.w(LOG_TAG_UI, "err opening the app conn bottom sheet") + Logger.w(LOG_TAG_UI, "$TAG err opening the app conn bottom sheet") return } + if (isRethink || isAsn) { + return + } + + Logger.vv(LOG_TAG_UI, "$TAG open bottom sheet for uid: $uid, ip: ${conn.ipAddress}, domain: ${conn.appOrDnsName}") val bottomSheetFragment = AppIpRulesBottomSheet() // Fix: free-form window crash // all BottomSheetDialogFragment classes created must have a public, no-arg constructor. @@ -131,13 +131,27 @@ class AppWiseIpsAdapter(val context: Context, val lifecycleOwner: LifecycleOwner private fun displayTransactionDetails(conn: AppConnection) { b.acdCount.text = conn.count.toString() - b.acdIpAddress.text = conn.ipAddress - b.acdFlag.text = conn.flag - if (!conn.appOrDnsName.isNullOrEmpty()) { - b.acdDomainName.visibility = View.VISIBLE - b.acdDomainName.text = beautifyDomainString(conn.appOrDnsName) + if (isAsn) { + b.acdIpAddress.text = conn.appOrDnsName + b.acdDomainName.text = conn.ipAddress + // in case of ASN, flag consists of country code, extract flag from it + val cc = Utilities.getFlag(conn.flag) + if (cc.isEmpty()) { + b.acdFlag.text = "--" + } else { + b.acdFlag.text = cc + } + b.acdDownArrowIv.visibility = View.INVISIBLE } else { - b.acdDomainName.visibility = View.GONE + b.acdFlag.text = conn.flag + b.acdIpAddress.text = conn.ipAddress + if (!conn.appOrDnsName.isNullOrEmpty()) { + b.acdDomainName.visibility = View.VISIBLE + b.acdDomainName.text = beautifyDomainString(conn.appOrDnsName) + } else { + b.acdDomainName.visibility = View.GONE + } + b.acdDownArrowIv.visibility = View.VISIBLE } updateStatusUi(conn) } diff --git a/app/src/full/java/com/celzero/bravedns/adapter/ConnectionTrackerAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/ConnectionTrackerAdapter.kt index 8799966b1..b80324e11 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/ConnectionTrackerAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/ConnectionTrackerAdapter.kt @@ -34,7 +34,7 @@ import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide import com.celzero.bravedns.R import com.celzero.bravedns.database.ConnectionTracker -import com.celzero.bravedns.databinding.ConnectionTransactionRowBinding +import com.celzero.bravedns.databinding.ListItemConnTrackBinding import com.celzero.bravedns.service.FirewallManager import com.celzero.bravedns.service.FirewallRuleset import com.celzero.bravedns.service.ProxyManager @@ -45,6 +45,7 @@ import com.celzero.bravedns.util.KnownPorts import com.celzero.bravedns.util.Protocol import com.celzero.bravedns.util.UIUtils.getDurationInHumanReadableFormat import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.getDefaultIcon import com.celzero.bravedns.util.Utilities.getIcon import com.google.gson.Gson import kotlinx.coroutines.Dispatchers @@ -61,25 +62,25 @@ class ConnectionTrackerAdapter(private val context: Context) : private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldConnection: ConnectionTracker, - newConnection: ConnectionTracker - ) = oldConnection.id == newConnection.id + override fun areItemsTheSame(old: ConnectionTracker, new: ConnectionTracker): Boolean { + return old.id == new.id + } - override fun areContentsTheSame( - oldConnection: ConnectionTracker, - newConnection: ConnectionTracker - ) = oldConnection.id == newConnection.id + override fun areContentsTheSame(old: ConnectionTracker, new: ConnectionTracker): Boolean { + return old == new + } } private const val MAX_BYTES = 500000 // 500 KB private const val MAX_TIME_TCP = 135 // seconds private const val MAX_TIME_UDP = 135 // seconds + private const val NO_USER_ID = 0 + private const val TAG = "ConnTrackAdapter" } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConnectionTrackerViewHolder { val itemBinding = - ConnectionTransactionRowBinding.inflate( + ListItemConnTrackBinding.inflate( LayoutInflater.from(parent.context), parent, false @@ -88,15 +89,32 @@ class ConnectionTrackerAdapter(private val context: Context) : } override fun onBindViewHolder(holder: ConnectionTrackerViewHolder, position: Int) { - val connTracker: ConnectionTracker = getItem(position) ?: return + val connTracker: ConnectionTracker? = getItem(position) + if (connTracker == null) { + holder.clear() + return + } holder.update(connTracker) holder.setTag(connTracker) } - inner class ConnectionTrackerViewHolder(private val b: ConnectionTransactionRowBinding) : + inner class ConnectionTrackerViewHolder(private val b: ListItemConnTrackBinding) : RecyclerView.ViewHolder(b.root) { + fun clear() { + b.connectionResponseTime.text = "" + b.connectionFlag.text = "" + b.connectionIpAddress.text = "" + b.connectionDomain.text = "" + b.connectionAppName.text = "" + b.connectionAppIcon.setImageDrawable(null) + b.connectionDataUsage.text = "" + b.connectionDelay.text = "" + b.connectionStatusIndicator.visibility = View.INVISIBLE + b.connectionSummaryLl.visibility = View.GONE + } + fun update(connTracker: ConnectionTracker) { displayTransactionDetails(connTracker) displayProtocolDetails(connTracker.port, connTracker.protocol) @@ -114,10 +132,11 @@ class ConnectionTrackerAdapter(private val context: Context) : private fun openBottomSheet(ct: ConnectionTracker) { if (context !is FragmentActivity) { - Logger.w(LOG_TAG_UI, "err opening the connection tracker bottomsheet") + Logger.w(LOG_TAG_UI, "$TAG err opening the connection tracker bottomsheet") return } + Logger.vv(LOG_TAG_UI, "$TAG show bottom sheet for ${ct.appName}") val bottomSheetFragment = ConnTrackerBottomSheet() // see AppIpRulesAdapter.kt#openBottomSheet() val bundle = Bundle() @@ -146,47 +165,43 @@ class ConnectionTrackerAdapter(private val context: Context) : private fun displayAppDetails(ct: ConnectionTracker) { io { uiCtx { - // append the usrId with app name if the usrId is not 0 - // fixme: move the 0 to a constant - if (ct.usrId != 0) { - b.connectionAppName.text = - context.getString( - R.string.about_version_install_source, - ct.appName, - ct.usrId.toString() - ) - } else { - b.connectionAppName.text = ct.appName - } - val apps = FirewallManager.getPackageNamesByUid(ct.uid) + val count = apps.count() - if (apps.isEmpty()) { - loadAppIcon(Utilities.getDefaultIcon(context)) - return@uiCtx - } + val appName = when { + ct.usrId != NO_USER_ID -> context.getString( + R.string.about_version_install_source, + ct.appName, + ct.usrId.toString() + ) - val count = apps.count() - val appName = - if (count > 1) { - context.getString( - R.string.ctbs_app_other_apps, - ct.appName, - (count).minus(1).toString() - ) - } else { - ct.appName - } + count > 1 -> context.getString( + R.string.ctbs_app_other_apps, + ct.appName, + "${count - 1}" + ) + + else -> ct.appName + } b.connectionAppName.text = appName - loadAppIcon(getIcon(context, apps[0], /*No app name */ "")) + if (apps.isEmpty()) { + loadAppIcon(getDefaultIcon(context)) + } else { + loadAppIcon(getIcon(context, apps[0])) + } } } } private fun displayProtocolDetails(port: Int, proto: Int) { - // Instead of showing the port name and protocol, now the ports are resolved with - // known ports(reserved port and protocol identifiers). + // If the protocol is not TCP or UDP, then display the protocol name. + if (Protocol.UDP.protocolType != proto && Protocol.TCP.protocolType != proto) { + b.connLatencyTxt.text = Protocol.getProtocolName(proto).name + return + } + + // Instead of displaying the port number, display the service name if it is known. // https://github.com/celzero/rethink-app/issues/42 - #3 - transport + protocol. val resolvedPort = KnownPorts.resolvePort(port) // case: for UDP/443 label it as HTTP3 instead of HTTPS @@ -251,7 +266,15 @@ class ConnectionTrackerAdapter(private val context: Context) : b.connectionDelay.text = "" } - if (isConnectionProxied(ct.blockedByRule, ct.proxyDetails)) { + if (isRpnProxy(ct.rpid)) { + b.connectionSummaryLl.visibility = View.VISIBLE + b.connectionDelay.text = + context.getString( + R.string.ci_desc, + b.connectionDelay.text, + context.getString(R.string.symbol_sparkle) + ) + } else if (isConnectionProxied(ct.blockedByRule, ct.proxyDetails)) { b.connectionSummaryLl.visibility = View.VISIBLE b.connectionDelay.text = context.getString( @@ -307,7 +330,24 @@ class ConnectionTrackerAdapter(private val context: Context) : context.getString(R.string.symbol_turtle) ) } - if (isConnectionProxied(ct.blockedByRule, ct.proxyDetails)) { + // bunny in case rpid as present, key in case of proxy + // bunny and key indicate conn is proxied, so its enough to show one of them + if (isRpnProxy(ct.rpid)) { + b.connectionSummaryLl.visibility = View.VISIBLE + b.connectionDelay.text = + context.getString( + R.string.ci_desc, + b.connectionDelay.text, + context.getString(R.string.symbol_sparkle) + ) + } else if (containsRelayProxy(ct.rpid)) { + b.connectionDelay.text = + context.getString( + R.string.ci_desc, + b.connectionDelay.text, + context.getString(R.string.symbol_bunny) + ) + } else if (isConnectionProxied(ct.blockedByRule, ct.proxyDetails)) { b.connectionDelay.text = context.getString( R.string.ci_desc, @@ -315,16 +355,39 @@ class ConnectionTrackerAdapter(private val context: Context) : context.getString(R.string.symbol_key) ) } + + // rtt -> show rocket if less than 20ms, treat it as rtt + if (isRoundTripShorter(ct.synack, ct.isBlocked)) { + b.connectionDelay.text = + context.getString( + R.string.ci_desc, + b.connectionDelay.text, + context.getString(R.string.symbol_rocket) + ) + } + if (b.connectionDelay.text.isEmpty() && b.connectionDataUsage.text.isEmpty()) { b.connectionSummaryLl.visibility = View.GONE } } + private fun isRoundTripShorter(rtt: Long, blocked: Boolean): Boolean { + return rtt in 1..20 && !blocked + } + + private fun containsRelayProxy(rpid: String): Boolean { + return rpid.isNotEmpty() + } + private fun isConnectionProxied(ruleName: String?, proxyDetails: String): Boolean { if (ruleName == null) return false val rule = FirewallRuleset.getFirewallRule(ruleName) ?: return false val proxy = ProxyManager.isIpnProxy(proxyDetails) - return FirewallRuleset.isProxied(rule) || proxy + return FirewallRuleset.isProxied(rule) && proxyDetails.isNotEmpty() && proxy + } + + private fun isRpnProxy(pid: String): Boolean { + return pid.isNotEmpty() && ProxyManager.isRpnProxy(pid) } private fun isConnectionHeavier(ct: ConnectionTracker): Boolean { @@ -339,7 +402,7 @@ class ConnectionTrackerAdapter(private val context: Context) : private fun loadAppIcon(drawable: Drawable?) { Glide.with(context) .load(drawable) - .error(Utilities.getDefaultIcon(context)) + .error(getDefaultIcon(context)) .into(b.connectionAppIcon) } } diff --git a/app/src/full/java/com/celzero/bravedns/adapter/ConsoleLogAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/ConsoleLogAdapter.kt new file mode 100644 index 000000000..09f0d765c --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/adapter/ConsoleLogAdapter.kt @@ -0,0 +1,100 @@ +/* + * Copyright 2024 RethinkDNS and its authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.celzero.bravedns.adapter + +import android.content.Context +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.celzero.bravedns.R +import com.celzero.bravedns.RethinkDnsApplication.Companion.DEBUG +import com.celzero.bravedns.database.ConsoleLog +import com.celzero.bravedns.databinding.ListItemConsoleLogBinding +import com.celzero.bravedns.util.Constants.Companion.TIME_FORMAT_1 +import com.celzero.bravedns.util.UIUtils +import com.celzero.bravedns.util.Utilities + +class ConsoleLogAdapter(private val context: Context) : + PagingDataAdapter(DIFF_CALLBACK) { + + companion object { + private val DIFF_CALLBACK = + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(old: ConsoleLog, new: ConsoleLog): Boolean { + return old == new + } + + override fun areContentsTheSame(old: ConsoleLog, new: ConsoleLog): Boolean { + return old == new + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConsoleLogViewHolder { + val itemBinding = + ListItemConsoleLogBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return ConsoleLogViewHolder(itemBinding) + } + + override fun onBindViewHolder(holder: ConsoleLogViewHolder, position: Int) { + val logInfo = getItem(position) ?: return + holder.update(logInfo) + } + + inner class ConsoleLogViewHolder(private val b: ListItemConsoleLogBinding) : + RecyclerView.ViewHolder(b.root) { + + fun update(log: ConsoleLog) { + // update the textview color with the first letter of the log level + val logLevel = log.message.firstOrNull() ?: 'V' + when (logLevel) { + 'V' -> + b.logDetail.setTextColor( + UIUtils.fetchColor(context, R.attr.primaryLightColorText) + ) + 'D' -> + b.logDetail.setTextColor( + UIUtils.fetchColor(context, R.attr.primaryLightColorText) + ) + 'I' -> + b.logDetail.setTextColor( + UIUtils.fetchColor(context, R.attr.defaultToggleBtnTxt) + ) + 'W' -> + b.logDetail.setTextColor( + UIUtils.fetchColor(context, R.attr.firewallWhiteListToggleBtnTxt) + ) + 'E' -> + b.logDetail.setTextColor( + UIUtils.fetchColor(context, R.attr.firewallBlockToggleBtnTxt) + ) + else -> + b.logDetail.setTextColor( + UIUtils.fetchColor(context, R.attr.primaryLightColorText) + ) + } + b.logDetail.text = log.message + if (DEBUG) { + b.logTimestamp.text = + "${log.id}\n${Utilities.convertLongToTime(log.timestamp, TIME_FORMAT_1)}" + } else { + b.logTimestamp.text = Utilities.convertLongToTime(log.timestamp, TIME_FORMAT_1) + } + } + } +} diff --git a/app/src/full/java/com/celzero/bravedns/adapter/CustomDomainAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/CustomDomainAdapter.kt index a8deb8cde..f470f0c6c 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/CustomDomainAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/CustomDomainAdapter.kt @@ -18,6 +18,7 @@ package com.celzero.bravedns.adapter import Logger import Logger.LOG_TAG_UI import android.content.Context +import android.content.Intent import android.content.res.ColorStateList import android.graphics.drawable.Drawable import android.text.format.DateUtils @@ -25,13 +26,16 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.WindowManager +import android.widget.CheckedTextView import android.widget.ImageView import android.widget.Toast import androidx.core.widget.addTextChangedListener +import androidx.fragment.app.Fragment import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide import com.celzero.bravedns.R @@ -44,22 +48,31 @@ import com.celzero.bravedns.service.DomainRulesManager.isValidDomain import com.celzero.bravedns.service.DomainRulesManager.isWildCardEntry import com.celzero.bravedns.service.FirewallManager import com.celzero.bravedns.ui.activity.CustomRulesActivity +import com.celzero.bravedns.ui.bottomsheet.CustomDomainRulesBtmSheet +import com.celzero.bravedns.ui.bottomsheet.CustomDomainRulesBtmSheet.ToggleBtnUi import com.celzero.bravedns.util.Constants import com.celzero.bravedns.util.UIUtils.fetchColor -import com.celzero.bravedns.util.UIUtils.fetchToggleBtnColors import com.celzero.bravedns.util.Utilities -import com.google.android.material.button.MaterialButton -import com.google.android.material.button.MaterialButtonToggleGroup +import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.net.URI -class CustomDomainAdapter(val context: Context, val rule: CustomRulesActivity.RULES) : +class CustomDomainAdapter( + val context: Context, + val fragment: Fragment, + val rule: CustomRulesActivity.RULES +) : PagingDataAdapter(DIFF_CALLBACK) { - companion object { + private val selectedItems = mutableSetOf() + private var isSelectionMode = false + private lateinit var adapter: CustomDomainAdapter + companion object { + private const val TAG = "CustomDomainAdapter" private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { override fun areItemsTheSame( @@ -67,22 +80,23 @@ class CustomDomainAdapter(val context: Context, val rule: CustomRulesActivity.RU newConnection: CustomDomain ): Boolean { return (oldConnection.domain == newConnection.domain && - oldConnection.status == newConnection.status) + oldConnection.status == newConnection.status && + oldConnection.proxyId == newConnection.proxyId && + oldConnection.proxyCC == newConnection.proxyCC) } override fun areContentsTheSame( oldConnection: CustomDomain, newConnection: CustomDomain ): Boolean { - return (oldConnection.domain == newConnection.domain && - oldConnection.status != newConnection.status) + return oldConnection == newConnection } } } - data class ToggleBtnUi(val txtColor: Int, val bgColor: Int) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + adapter = this if (viewType == R.layout.list_item_custom_all_domain) { val itemBinding = ListItemCustomAllDomainBinding.inflate( @@ -104,16 +118,30 @@ class CustomDomainAdapter(val context: Context, val rule: CustomRulesActivity.RU override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { val customDomain: CustomDomain = getItem(position) ?: return - if (holder is CustomDomainViewHolderWithHeader) { - holder.update(customDomain) - } else if (holder is CustomDomainViewHolderWithoutHeader) { - holder.update(customDomain) - } else { - Logger.w(LOG_TAG_UI, "unknown view holder in CustomDomainRulesAdapter") - return + when (holder) { + is CustomDomainViewHolderWithHeader -> { + holder.update(customDomain) + } + + is CustomDomainViewHolderWithoutHeader -> { + holder.update(customDomain) + } + + else -> { + Logger.w(LOG_TAG_UI, "unknown view holder in CustomDomainRulesAdapter") + return + } } } + fun getSelectedItems(): List = selectedItems.toList() + + fun clearSelection() { + selectedItems.clear() + isSelectionMode = false + notifyDataSetChanged() + } + override fun getItemViewType(position: Int): Int { if (rule == CustomRulesActivity.RULES.APP_SPECIFIC_RULES) { return R.layout.list_item_custom_domain @@ -135,105 +163,6 @@ class CustomDomainAdapter(val context: Context, val rule: CustomRulesActivity.RU .into(mIconImageView) } - private fun changeDomainStatus(id: DomainRulesManager.Status, cd: CustomDomain) { - io { - when (id) { - DomainRulesManager.Status.NONE -> { - noRule(cd) - } - DomainRulesManager.Status.BLOCK -> { - block(cd) - } - DomainRulesManager.Status.TRUST -> { - whitelist(cd) - } - } - } - } - - private suspend fun whitelist(cd: CustomDomain) { - DomainRulesManager.trust(cd) - } - - private suspend fun block(cd: CustomDomain) { - DomainRulesManager.block(cd) - } - - private suspend fun noRule(cd: CustomDomain) { - DomainRulesManager.noRule(cd) - } - - private fun findSelectedRuleByTag(ruleId: Int): DomainRulesManager.Status? { - return when (ruleId) { - DomainRulesManager.Status.NONE.id -> { - DomainRulesManager.Status.NONE - } - DomainRulesManager.Status.TRUST.id -> { - DomainRulesManager.Status.TRUST - } - DomainRulesManager.Status.BLOCK.id -> { - DomainRulesManager.Status.BLOCK - } - else -> { - null - } - } - } - - private fun toggleBtnUi(id: DomainRulesManager.Status): ToggleBtnUi { - return when (id) { - DomainRulesManager.Status.NONE -> { - ToggleBtnUi( - fetchColor(context, R.attr.chipTextNeutral), - fetchColor(context, R.attr.chipBgColorNeutral) - ) - } - DomainRulesManager.Status.BLOCK -> { - ToggleBtnUi( - fetchColor(context, R.attr.chipTextNegative), - fetchColor(context, R.attr.chipBgColorNegative) - ) - } - DomainRulesManager.Status.TRUST -> { - ToggleBtnUi( - fetchColor(context, R.attr.chipTextPositive), - fetchColor(context, R.attr.chipBgColorPositive) - ) - } - } - } - - private fun selectToggleBtnUi(b: MaterialButton, toggleBtnUi: ToggleBtnUi) { - b.setTextColor(toggleBtnUi.txtColor) - b.backgroundTintList = ColorStateList.valueOf(toggleBtnUi.bgColor) - } - - private fun unselectToggleBtnUi(b: MaterialButton) { - b.setTextColor(fetchToggleBtnColors(context, R.color.defaultToggleBtnTxt)) - b.backgroundTintList = - ColorStateList.valueOf(fetchToggleBtnColors(context, R.color.defaultToggleBtnBg)) - } - - private fun showDialogForDelete(customDomain: CustomDomain) { - val builder = MaterialAlertDialogBuilder(context) - builder.setTitle(R.string.cd_remove_dialog_title) - builder.setMessage(R.string.cd_remove_dialog_message) - builder.setCancelable(true) - builder.setPositiveButton(context.getString(R.string.lbl_delete)) { _, _ -> - io { DomainRulesManager.deleteDomain(customDomain) } - Utilities.showToastUiCentered( - context, - context.getString(R.string.cd_toast_deleted), - Toast.LENGTH_SHORT - ) - } - - builder.setNegativeButton(context.getString(R.string.lbl_cancel)) { _, _ -> - // no-op - } - builder.create().show() - } - private fun showEditDomainDialog(customDomain: CustomDomain) { val dBind = DialogAddCustomDomainBinding.inflate((context as CustomRulesActivity).layoutInflater) @@ -251,24 +180,24 @@ class CustomDomainAdapter(val context: Context, val rule: CustomRulesActivity.RU var selectedType: DomainRulesManager.DomainType = DomainRulesManager.DomainType.getType(customDomain.type) - when (selectedType) { - DomainRulesManager.DomainType.DOMAIN -> { - dBind.dacdDomainChip.isChecked = true - } - DomainRulesManager.DomainType.WILDCARD -> { - dBind.dacdWildcardChip.isChecked = true - } + if (customDomain.domain.startsWith("*") || customDomain.domain.startsWith(".")) { + dBind.dacdWildcardChip.isChecked = true + } else { + dBind.dacdDomainChip.isChecked = true } dBind.dacdDomainEditText.setText(customDomain.domain) dBind.dacdDomainEditText.addTextChangedListener { - if (it?.contains("*") == true) { + if (it?.startsWith("*") == true || it?.startsWith(".") == true) { dBind.dacdWildcardChip.isChecked = true + } else { + dBind.dacdDomainChip.isChecked = true } } dBind.dacdDomainChip.setOnCheckedChangeListener { _, isSelected -> + Logger.vv(LOG_TAG_UI, "$TAG domain chip selected: $isSelected") if (isSelected) { selectedType = DomainRulesManager.DomainType.DOMAIN dBind.dacdDomainEditText.hint = @@ -285,6 +214,7 @@ class CustomDomainAdapter(val context: Context, val rule: CustomRulesActivity.RU } dBind.dacdWildcardChip.setOnCheckedChangeListener { _, isSelected -> + Logger.vv(LOG_TAG_UI, "$TAG wildcard chip selected: $isSelected") if (isSelected) { selectedType = DomainRulesManager.DomainType.WILDCARD dBind.dacdDomainEditText.hint = @@ -332,28 +262,39 @@ class CustomDomainAdapter(val context: Context, val rule: CustomRulesActivity.RU ) { dBind.dacdFailureText.visibility = View.GONE val url = dBind.dacdDomainEditText.text.toString().trim() + val extractedHost = extractHost(url) ?: run { + dBind.dacdFailureText.text = + context.getString(R.string.cd_dialog_error_invalid_domain) + dBind.dacdFailureText.visibility = View.VISIBLE + Logger.vv(LOG_TAG_UI, "$TAG invalid domain: $url") + return + } when (selectedType) { DomainRulesManager.DomainType.WILDCARD -> { - if (!isWildCardEntry(url)) { + if (!isWildCardEntry(extractedHost)) { dBind.dacdFailureText.text = context.getString(R.string.cd_dialog_error_invalid_wildcard) dBind.dacdFailureText.visibility = View.VISIBLE + Logger.vv(LOG_TAG_UI, "$TAG invalid wildcard domain: $url") return } } + DomainRulesManager.DomainType.DOMAIN -> { - if (!isValidDomain(url)) { + if (!isValidDomain(extractedHost)) { dBind.dacdFailureText.text = context.getString(R.string.cd_dialog_error_invalid_domain) dBind.dacdFailureText.visibility = View.VISIBLE + Logger.vv(LOG_TAG_UI, "$TAG invalid domain: $url") return } } } io { + Logger.vv(LOG_TAG_UI, "$TAG domain: $extractedHost, type: $selectedType") insertDomain( - Utilities.removeLeadingAndTrailingDots(url), + Utilities.removeLeadingAndTrailingDots(extractedHost), selectedType, prevDomain, status @@ -361,12 +302,48 @@ class CustomDomainAdapter(val context: Context, val rule: CustomRulesActivity.RU } } + // fixme: same as extractHost in CustomDomainFragment, should be moved to a common place + private fun extractHost(input: String): String? { + val trimmedInput = input.trim() + + return when { + // case: valid wildcard input without schema, eg., *.example.com + trimmedInput.startsWith("*.") && !trimmedInput.contains("://") -> { + trimmedInput + } + + // case: invalid wildcard with schema, eg., https://*.example.com + trimmedInput.contains("://") && trimmedInput.contains("*") -> { + null // Invalid: Wildcards shouldn't appear in URLs + } + + // case: standard URL input, eg., https://www.example.com + trimmedInput.contains("://") -> { + try { + // return the host part of the URL + // only www. is the common prefix you'd want to strip for cosmetic or + // standardization reasons (like www.google.com → google.com). Other subdomains + // (e.g., mail., api., m.) are actually part of the valid hostname and + // should not be removed + val uri = URI(trimmedInput) + uri.host?.removePrefix("www.") // remove 'www.' prefix if present + } catch (e: Exception) { + null + } + } + + // case: plain domain (no schema, no wildcard), eg., example.com + else -> trimmedInput + } + } + private suspend fun insertDomain( domain: String, type: DomainRulesManager.DomainType, prevDomain: CustomDomain, status: DomainRulesManager.Status ) { + Logger.i(LOG_TAG_UI, "$TAG insert/update domain: $domain, type: $type") DomainRulesManager.updateDomainRule(domain, status, type, prevDomain) uiCtx { Utilities.showToastUiCentered( @@ -383,6 +360,10 @@ class CustomDomainAdapter(val context: Context, val rule: CustomRulesActivity.RU private lateinit var customDomain: CustomDomain fun update(cd: CustomDomain) { + this.customDomain = cd + + b.customDomainCheckbox.isChecked = selectedItems.contains(cd) + b.customDomainCheckbox.visibility = if (isSelectionMode) View.VISIBLE else View.GONE io { val appInfo = FirewallManager.getAppInfoByUid(cd.uid) val appNames = FirewallManager.getAppNamesByUid(cd.uid) @@ -399,31 +380,62 @@ class CustomDomainAdapter(val context: Context, val rule: CustomRulesActivity.RU b.customDomainAppIconIv ) - this.customDomain = cd + b.customDomainLabelTv.text = customDomain.domain - b.customDomainToggleGroup.tag = 1 - // update toggle group button based on the status - updateToggleGroup(customDomain.status) - // whether to show the toggle group or not - toggleActionsUi() // update status in desc and status flag (N/B/W) updateStatusUi( - DomainRulesManager.Status.getStatus(customDomain.status), - customDomain.modifiedTs + DomainRulesManager.Status.getStatus(cd.status), + cd.modifiedTs ) - b.customDomainToggleGroup.addOnButtonCheckedListener(domainRulesGroupListener) + b.customDomainEditIcon.setOnClickListener { showEditDomainDialog(cd) } - b.customDomainEditIcon.setOnClickListener { showEditDomainDialog(customDomain) } + b.customDomainExpandIcon.setOnClickListener { + showButtonsBottomSheet(customDomain) + //toggleActionsUi() + } + + b.customDomainContainer.setOnClickListener { + if (isSelectionMode) { + toggleSelection(cd) + } else { + showButtonsBottomSheet(customDomain) + } + } - b.customDomainExpandIcon.setOnClickListener { toggleActionsUi() } + b.customDomainSeeMoreChip.setOnClickListener { openAppWiseRulesActivity(cd.uid) } - b.customDomainContainer.setOnClickListener { toggleActionsUi() } + b.customDomainContainer.setOnLongClickListener { + isSelectionMode = true + selectedItems.add(cd) + notifyDataSetChanged() + true + } } } } + private fun toggleSelection(item: CustomDomain) { + if (selectedItems.contains(item)) { + selectedItems.remove(item) + b.customDomainCheckbox.isChecked = false + } else { + selectedItems.add(item) + b.customDomainCheckbox.isChecked = true + } + } + + private fun openAppWiseRulesActivity(uid: Int) { + val intent = Intent(context, CustomRulesActivity::class.java) + intent.putExtra( + Constants.VIEW_PAGER_SCREEN_TO_LOAD, + CustomRulesActivity.Tabs.DOMAIN_RULES.screen + ) + intent.putExtra(Constants.INTENT_UID, uid) + context.startActivity(intent) + } + private fun getAppName(uid: Int, appNames: List): String { if (uid == Constants.UID_EVERYBODY) { return context @@ -447,85 +459,6 @@ class CustomDomainAdapter(val context: Context, val rule: CustomRulesActivity.RU } } - private fun updateToggleGroup(id: Int) { - val fid = findSelectedRuleByTag(id) ?: return - - val t = toggleBtnUi(fid) - - when (id) { - DomainRulesManager.Status.NONE.id -> { - b.customDomainToggleGroup.check(b.customDomainTgNoRule.id) - selectToggleBtnUi(b.customDomainTgNoRule, t) - unselectToggleBtnUi(b.customDomainTgBlock) - unselectToggleBtnUi(b.customDomainTgWhitelist) - } - DomainRulesManager.Status.BLOCK.id -> { - b.customDomainToggleGroup.check(b.customDomainTgBlock.id) - selectToggleBtnUi(b.customDomainTgBlock, t) - unselectToggleBtnUi(b.customDomainTgNoRule) - unselectToggleBtnUi(b.customDomainTgWhitelist) - } - DomainRulesManager.Status.TRUST.id -> { - b.customDomainToggleGroup.check(b.customDomainTgWhitelist.id) - selectToggleBtnUi(b.customDomainTgWhitelist, t) - unselectToggleBtnUi(b.customDomainTgBlock) - unselectToggleBtnUi(b.customDomainTgNoRule) - } - } - } - - private val domainRulesGroupListener = - MaterialButtonToggleGroup.OnButtonCheckedListener { group, checkedId, isChecked -> - val b: MaterialButton = b.customDomainToggleGroup.findViewById(checkedId) - - val statusId = findSelectedRuleByTag(getTag(b.tag)) - // delete button - if (statusId == null && isChecked) { - group.clearChecked() - showDialogForDelete(customDomain) - return@OnButtonCheckedListener - } - - // invalid selection - if (statusId == null) { - return@OnButtonCheckedListener - } - - if (isChecked) { - // See CustomIpAdapter.kt for the same code (ipRulesGroupListener) - val hasStatusChanged = customDomain.status != statusId.id - if (!hasStatusChanged) { - return@OnButtonCheckedListener - } - val t = toggleBtnUi(statusId) - // update toggle button - selectToggleBtnUi(b, t) - // update status in desc and status flag (N/B/W) - updateStatusUi(statusId, customDomain.modifiedTs) - // change status based on selected btn - changeDomainStatus(statusId, customDomain) - } else { - unselectToggleBtnUi(b) - } - } - - // each button in the toggle group is associated with tag value. - // tag values are ids of DomainRulesManager.DomainStatus - private fun getTag(tag: Any): Int { - return tag.toString().toIntOrNull() ?: 0 - } - - private fun toggleActionsUi() { - if (b.customDomainToggleGroup.tag == 0) { - b.customDomainToggleGroup.tag = 1 - b.customDomainToggleGroup.visibility = View.VISIBLE - return - } - - b.customDomainToggleGroup.tag = 0 - b.customDomainToggleGroup.visibility = View.GONE - } - private fun updateStatusUi(status: DomainRulesManager.Status, modifiedTs: Long) { val now = System.currentTimeMillis() val uptime = System.currentTimeMillis() - modifiedTs @@ -547,6 +480,7 @@ class CustomDomainAdapter(val context: Context, val rule: CustomRulesActivity.RU time ) } + DomainRulesManager.Status.BLOCK -> { b.customDomainStatusIcon.text = context.getString(R.string.cd_blocked_initial) b.customDomainStatusTv.text = @@ -556,6 +490,7 @@ class CustomDomainAdapter(val context: Context, val rule: CustomRulesActivity.RU time ) } + DomainRulesManager.Status.NONE -> { b.customDomainStatusIcon.text = context.getString(R.string.cd_no_rule_initial) b.customDomainStatusTv.text = @@ -581,105 +516,47 @@ class CustomDomainAdapter(val context: Context, val rule: CustomRulesActivity.RU fun update(cd: CustomDomain) { this.customDomain = cd + b.customDomainCheckbox.isChecked = selectedItems.contains(cd) + b.customDomainCheckbox.visibility = if (isSelectionMode) View.VISIBLE else View.GONE b.customDomainLabelTv.text = customDomain.domain - b.customDomainToggleGroup.tag = 1 - // update toggle group button based on the status - updateToggleGroup(customDomain.status) - // whether to show the toggle group or not - toggleActionsUi() // update status in desc and status flag (N/B/W) updateStatusUi( DomainRulesManager.Status.getStatus(customDomain.status), customDomain.modifiedTs ) - b.customDomainToggleGroup.addOnButtonCheckedListener(domainRulesGroupListener) - b.customDomainEditIcon.setOnClickListener { showEditDomainDialog(customDomain) } - b.customDomainExpandIcon.setOnClickListener { toggleActionsUi() } - - b.customDomainContainer.setOnClickListener { toggleActionsUi() } - } - - private fun updateToggleGroup(id: Int) { - val fid = findSelectedRuleByTag(id) ?: return - - val t = toggleBtnUi(fid) - - when (id) { - DomainRulesManager.Status.NONE.id -> { - b.customDomainToggleGroup.check(b.customDomainTgNoRule.id) - selectToggleBtnUi(b.customDomainTgNoRule, t) - unselectToggleBtnUi(b.customDomainTgBlock) - unselectToggleBtnUi(b.customDomainTgWhitelist) - } - DomainRulesManager.Status.BLOCK.id -> { - b.customDomainToggleGroup.check(b.customDomainTgBlock.id) - selectToggleBtnUi(b.customDomainTgBlock, t) - unselectToggleBtnUi(b.customDomainTgNoRule) - unselectToggleBtnUi(b.customDomainTgWhitelist) - } - DomainRulesManager.Status.TRUST.id -> { - b.customDomainToggleGroup.check(b.customDomainTgWhitelist.id) - selectToggleBtnUi(b.customDomainTgWhitelist, t) - unselectToggleBtnUi(b.customDomainTgBlock) - unselectToggleBtnUi(b.customDomainTgNoRule) - } + b.customDomainExpandIcon.setOnClickListener { + showButtonsBottomSheet(customDomain) + //toggleActionsUi() } - } - - private val domainRulesGroupListener = - MaterialButtonToggleGroup.OnButtonCheckedListener { group, checkedId, isChecked -> - val b: MaterialButton = b.customDomainToggleGroup.findViewById(checkedId) - - val statusId = findSelectedRuleByTag(getTag(b.tag)) - // delete button - if (statusId == null && isChecked) { - group.clearChecked() - showDialogForDelete(customDomain) - return@OnButtonCheckedListener - } - - // invalid selection - if (statusId == null) { - return@OnButtonCheckedListener - } - if (isChecked) { - // See CustomIpAdapter.kt for the same code (ipRulesGroupListener) - val hasStatusChanged = customDomain.status != statusId.id - if (!hasStatusChanged) { - return@OnButtonCheckedListener - } - val t = toggleBtnUi(statusId) - // update toggle button - selectToggleBtnUi(b, t) - // update status in desc and status flag (N/B/W) - updateStatusUi(statusId, customDomain.modifiedTs) - // change status based on selected btn - changeDomainStatus(statusId, customDomain) + b.customDomainContainer.setOnClickListener { + if (isSelectionMode) { + toggleSelection(cd) } else { - unselectToggleBtnUi(b) + showButtonsBottomSheet(customDomain) } } - // each button in the toggle group is associated with tag value. - // tag values are ids of DomainRulesManager.DomainStatus - private fun getTag(tag: Any): Int { - return tag.toString().toIntOrNull() ?: 0 + b.customDomainContainer.setOnLongClickListener { + isSelectionMode = true + selectedItems.add(cd) + notifyDataSetChanged() + true + } } - private fun toggleActionsUi() { - if (b.customDomainToggleGroup.tag == 0) { - b.customDomainToggleGroup.tag = 1 - b.customDomainToggleGroup.visibility = View.VISIBLE - return + private fun toggleSelection(item: CustomDomain) { + if (selectedItems.contains(item)) { + selectedItems.remove(item) + b.customDomainCheckbox.isChecked = false + } else { + selectedItems.add(item) + b.customDomainCheckbox.isChecked = true } - - b.customDomainToggleGroup.tag = 0 - b.customDomainToggleGroup.visibility = View.GONE } private fun updateStatusUi(status: DomainRulesManager.Status, modifiedTs: Long) { @@ -703,6 +580,7 @@ class CustomDomainAdapter(val context: Context, val rule: CustomRulesActivity.RU time ) } + DomainRulesManager.Status.BLOCK -> { b.customDomainStatusIcon.text = context.getString(R.string.cd_blocked_initial) b.customDomainStatusTv.text = @@ -712,6 +590,7 @@ class CustomDomainAdapter(val context: Context, val rule: CustomRulesActivity.RU time ) } + DomainRulesManager.Status.NONE -> { b.customDomainStatusIcon.text = context.getString(R.string.cd_no_rule_initial) b.customDomainStatusTv.text = @@ -730,6 +609,36 @@ class CustomDomainAdapter(val context: Context, val rule: CustomRulesActivity.RU } } + private fun toggleBtnUi(id: DomainRulesManager.Status): ToggleBtnUi { + return when (id) { + DomainRulesManager.Status.NONE -> { + ToggleBtnUi( + fetchColor(context, R.attr.chipTextNeutral), + fetchColor(context, R.attr.chipBgColorNeutral) + ) + } + + DomainRulesManager.Status.BLOCK -> { + ToggleBtnUi( + fetchColor(context, R.attr.chipTextNegative), + fetchColor(context, R.attr.chipBgColorNegative) + ) + } + + DomainRulesManager.Status.TRUST -> { + ToggleBtnUi( + fetchColor(context, R.attr.chipTextPositive), + fetchColor(context, R.attr.chipBgColorPositive) + ) + } + } + } + + private fun showButtonsBottomSheet(customDomain: CustomDomain) { + val bottomSheetFragment = CustomDomainRulesBtmSheet(customDomain) + bottomSheetFragment.show(fragment.parentFragmentManager, bottomSheetFragment.tag) + } + private fun io(f: suspend () -> Unit) { (context as LifecycleOwner).lifecycleScope.launch(Dispatchers.IO) { f() } } @@ -738,3 +647,4 @@ class CustomDomainAdapter(val context: Context, val rule: CustomRulesActivity.RU withContext(Dispatchers.Main) { f() } } } + diff --git a/app/src/full/java/com/celzero/bravedns/adapter/CustomIpAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/CustomIpAdapter.kt index 8e748bd97..f930d4896 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/CustomIpAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/CustomIpAdapter.kt @@ -18,6 +18,7 @@ package com.celzero.bravedns.adapter import Logger import Logger.LOG_TAG_UI import android.content.Context +import android.content.Intent import android.content.res.ColorStateList import android.graphics.drawable.Drawable import android.text.format.DateUtils @@ -43,14 +44,13 @@ import com.celzero.bravedns.databinding.ListItemCustomIpBinding import com.celzero.bravedns.service.FirewallManager import com.celzero.bravedns.service.IpRulesManager import com.celzero.bravedns.ui.activity.CustomRulesActivity +import com.celzero.bravedns.ui.bottomsheet.CustomIpRulesBtmSheet +import com.celzero.bravedns.util.Constants import com.celzero.bravedns.util.Constants.Companion.UID_EVERYBODY import com.celzero.bravedns.util.UIUtils.fetchColor -import com.celzero.bravedns.util.UIUtils.fetchToggleBtnColors import com.celzero.bravedns.util.Utilities import com.celzero.bravedns.util.Utilities.getCountryCode import com.celzero.bravedns.util.Utilities.getFlag -import com.google.android.material.button.MaterialButton -import com.google.android.material.button.MaterialButtonToggleGroup import com.google.android.material.dialog.MaterialAlertDialogBuilder import inet.ipaddr.IPAddress import inet.ipaddr.IPAddressString @@ -61,17 +61,22 @@ import kotlinx.coroutines.withContext class CustomIpAdapter(private val context: Context, private val type: CustomRulesActivity.RULES) : PagingDataAdapter(DIFF_CALLBACK) { + private val selectedItems = mutableSetOf() + private var isSelectionMode = false + companion object { + private const val TAG = "CustomIpAdapter" private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldConnection: CustomIp, newConnection: CustomIp) = oldConnection.ipAddress == newConnection.ipAddress && - oldConnection.status == newConnection.status + oldConnection.status == newConnection.status && + oldConnection.proxyCC == newConnection.proxyCC && + oldConnection.proxyId == newConnection.proxyId override fun areContentsTheSame(oldConnection: CustomIp, newConnection: CustomIp) = - oldConnection.ipAddress == newConnection.ipAddress && - oldConnection.status != newConnection.status + oldConnection == newConnection } } @@ -96,15 +101,17 @@ class CustomIpAdapter(private val context: Context, private val type: CustomRule override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { val customIp: CustomIp = getItem(position) ?: return + when (holder) { - is CustomIpAdapter.CustomIpsViewHolderWithHeader -> { + is CustomIpsViewHolderWithHeader -> { holder.update(customIp) + } - is CustomIpAdapter.CustomIpsViewHolderWithoutHeader -> { + is CustomIpsViewHolderWithoutHeader -> { holder.update(customIp) } else -> { - Logger.w(LOG_TAG_UI, "unknown view holder in CustomDomainRulesAdapter") + Logger.w(LOG_TAG_UI, "$TAG unknown view holder in CustomDomainRulesAdapter") return } } @@ -125,6 +132,14 @@ class CustomIpAdapter(private val context: Context, private val type: CustomRule } } + fun getSelectedItems(): List = selectedItems.toList() + + fun clearSelection() { + selectedItems.clear() + isSelectionMode = false + notifyDataSetChanged() + } + private fun displayIcon(drawable: Drawable?, mIconImageView: ImageView) { Glide.with(context) .load(drawable) @@ -132,25 +147,6 @@ class CustomIpAdapter(private val context: Context, private val type: CustomRule .into(mIconImageView) } - private fun changeIpStatus(id: IpRulesManager.IpRuleStatus, customIp: CustomIp) { - io { - when (id) { - IpRulesManager.IpRuleStatus.NONE -> { - noRuleIp(customIp) - } - IpRulesManager.IpRuleStatus.BLOCK -> { - blockIp(customIp) - } - IpRulesManager.IpRuleStatus.BYPASS_UNIVERSAL -> { - byPassUniversal(customIp) - } - IpRulesManager.IpRuleStatus.TRUST -> { - byPassAppRule(customIp) - } - } - } - } - private fun getToggleBtnUiParams(id: IpRulesManager.IpRuleStatus): ToggleBtnUi { return when (id) { IpRulesManager.IpRuleStatus.NONE -> { @@ -180,17 +176,6 @@ class CustomIpAdapter(private val context: Context, private val type: CustomRule } } - private fun selectToggleBtnUi(btn: MaterialButton, toggleBtnUi: ToggleBtnUi) { - btn.setTextColor(toggleBtnUi.txtColor) - btn.backgroundTintList = ColorStateList.valueOf(toggleBtnUi.bgColor) - } - - private fun unselectToggleBtnUi(btn: MaterialButton) { - btn.setTextColor(fetchToggleBtnColors(context, R.color.defaultToggleBtnTxt)) - btn.backgroundTintList = - ColorStateList.valueOf(fetchToggleBtnColors(context, R.color.defaultToggleBtnBg)) - } - private fun findSelectedIpRule(ruleId: Int): IpRulesManager.IpRuleStatus? { return when (ruleId) { IpRulesManager.IpRuleStatus.NONE.id -> { @@ -211,28 +196,16 @@ class CustomIpAdapter(private val context: Context, private val type: CustomRule } } - private suspend fun byPassUniversal(customIp: CustomIp) { - IpRulesManager.updateBypass(customIp) - } - - private suspend fun byPassAppRule(customIp: CustomIp) { - IpRulesManager.updateTrust(customIp) - } - - private suspend fun blockIp(customIp: CustomIp) { - IpRulesManager.updateBlock(customIp) - } - - private suspend fun noRuleIp(customIp: CustomIp) { - IpRulesManager.updateNoRule(customIp) - } - inner class CustomIpsViewHolderWithHeader(private val b: ListItemCustomAllIpBinding) : RecyclerView.ViewHolder(b.root) { private lateinit var customIp: CustomIp fun update(ci: CustomIp) { + customIp = ci + + b.customIpCheckbox.isChecked = selectedItems.contains(customIp) + b.customIpCheckbox.visibility = if (isSelectionMode) View.VISIBLE else View.GONE io { val appNames = FirewallManager.getAppNamesByUid(ci.uid) val appName = getAppName(ci.uid, appNames) @@ -250,34 +223,65 @@ class CustomIpAdapter(private val context: Context, private val type: CustomRule } } - customIp = ci + b.customIpLabelTv.text = context.getString( R.string.ci_ip_label, customIp.ipAddress, customIp.port.toString() ) - b.customIpToggleGroup.tag = 1 val status = findSelectedIpRule(customIp.status) ?: return - // decide whether to show bypass-universal or bypass-app rule - showBypassUi(ci.uid) - // whether to show the toggle group or not - toggleActionsUi() - // update toggle group button based on the status - updateToggleGroup(status) // update flag for the available ips updateFlagIfAvailable(customIp) // update status in desc and status flag (N/B/W) updateStatusUi(status) - b.customIpToggleGroup.addOnButtonCheckedListener(ipRulesGroupListener) - b.customIpEditIcon.setOnClickListener { showEditIpDialog(customIp) } - b.customIpExpandIcon.setOnClickListener { toggleActionsUi() } + b.customIpExpandIcon.setOnClickListener { showBtmSheet() } + + b.customIpContainer.setOnClickListener { + if (isSelectionMode) { + toggleSelection(customIp) + } else { + showBtmSheet() + } + } + + b.customIpSeeMoreChip.setOnClickListener { openAppWiseRulesActivity(customIp.uid) } + + b.customIpContainer.setOnLongClickListener { + isSelectionMode = true + selectedItems.add(customIp) + notifyDataSetChanged() + true + } + } + + private fun showBtmSheet() { + val bottomSheet = CustomIpRulesBtmSheet(customIp) + bottomSheet.show((context as CustomRulesActivity).supportFragmentManager, bottomSheet.tag) + } + + private fun toggleSelection(item: CustomIp) { + if (selectedItems.contains(item)) { + selectedItems.remove(item) + b.customIpCheckbox.isChecked = false + } else { + selectedItems.add(item) + b.customIpCheckbox.isChecked = true + } + } - b.customIpContainer.setOnClickListener { toggleActionsUi() } + private fun openAppWiseRulesActivity(uid: Int) { + val intent = Intent(context, CustomRulesActivity::class.java) + intent.putExtra( + Constants.VIEW_PAGER_SCREEN_TO_LOAD, + CustomRulesActivity.Tabs.IP_RULES.screen + ) + intent.putExtra(Constants.INTENT_UID, uid) + context.startActivity(intent) } private fun getAppName(uid: Int, appNames: List): String { @@ -303,136 +307,16 @@ class CustomIpAdapter(private val context: Context, private val type: CustomRule } } - private fun showBypassUi(uid: Int) { - if (uid == UID_EVERYBODY) { - b.customIpTgBypassUniv.visibility = View.VISIBLE - b.customIpTgBypassApp.visibility = View.GONE - } else { - b.customIpTgBypassUniv.visibility = View.GONE - b.customIpTgBypassApp.visibility = View.VISIBLE - } - } - - private fun updateToggleGroup(id: IpRulesManager.IpRuleStatus) { - val t = getToggleBtnUiParams(id) - - when (id) { - IpRulesManager.IpRuleStatus.NONE -> { - b.customIpToggleGroup.check(b.customIpTgNoRule.id) - selectToggleBtnUi(b.customIpTgNoRule, t) - unselectToggleBtnUi(b.customIpTgBlock) - unselectToggleBtnUi(b.customIpTgBypassUniv) - unselectToggleBtnUi(b.customIpTgBypassApp) - } - IpRulesManager.IpRuleStatus.BLOCK -> { - b.customIpToggleGroup.check(b.customIpTgBlock.id) - selectToggleBtnUi(b.customIpTgBlock, t) - unselectToggleBtnUi(b.customIpTgNoRule) - unselectToggleBtnUi(b.customIpTgBypassUniv) - unselectToggleBtnUi(b.customIpTgBypassApp) - } - IpRulesManager.IpRuleStatus.BYPASS_UNIVERSAL -> { - b.customIpToggleGroup.check(b.customIpTgBypassUniv.id) - selectToggleBtnUi(b.customIpTgBypassUniv, t) - unselectToggleBtnUi(b.customIpTgBlock) - unselectToggleBtnUi(b.customIpTgNoRule) - unselectToggleBtnUi(b.customIpTgBypassApp) - } - IpRulesManager.IpRuleStatus.TRUST -> { - b.customIpToggleGroup.check(b.customIpTgBypassApp.id) - selectToggleBtnUi(b.customIpTgBypassApp, t) - unselectToggleBtnUi(b.customIpTgBlock) - unselectToggleBtnUi(b.customIpTgNoRule) - unselectToggleBtnUi(b.customIpTgBypassUniv) - } - } - } - - private val ipRulesGroupListener = - MaterialButtonToggleGroup.OnButtonCheckedListener { group, checkedId, isChecked -> - val b: MaterialButton = b.customIpToggleGroup.findViewById(checkedId) - val statusId = findSelectedIpRule(getTag(b.tag)) - // delete button - if (statusId == null && isChecked) { - group.clearChecked() - showDialogForDelete(customIp) - return@OnButtonCheckedListener - } - - // invalid selection - if (statusId == null) { - return@OnButtonCheckedListener - } - - if (isChecked) { - // checked change listener is called multiple times, even for position change - // so, check if the status has changed or not - // also see CustomDomainAdapter#domainRulesGroupListener - val hasStatusChanged = customIp.status != statusId.id - if (hasStatusChanged) { - val t = getToggleBtnUiParams(statusId) - // update the toggle button - selectToggleBtnUi(b, t) - // update the status in desc and status flag (N/B/BU) - updateStatusUi(statusId) - - changeIpStatus(statusId, customIp) - } else { - // no-op - } - } else { - unselectToggleBtnUi(b) - } - } - - // each button in the toggle group is associated with tag value. - // tag values are ids of the IpRulesManager.IpRuleStatus - private fun getTag(tag: Any): Int { - return tag.toString().toIntOrNull() ?: 0 - } - - private fun showDialogForDelete(customIp: CustomIp) { - val builder = MaterialAlertDialogBuilder(context) - builder.setTitle(R.string.univ_firewall_dialog_title) - builder.setMessage(R.string.univ_firewall_dialog_message) - builder.setCancelable(true) - builder.setPositiveButton(context.getString(R.string.lbl_delete)) { _, _ -> - io { IpRulesManager.removeIpRule(customIp.uid, customIp.ipAddress, customIp.port) } - Utilities.showToastUiCentered( - context, - context.getString(R.string.univ_ip_delete_individual_toast, customIp.ipAddress), - Toast.LENGTH_SHORT - ) - } - - builder.setNegativeButton(context.getString(R.string.lbl_cancel)) { _, _ -> - updateStatusUi(IpRulesManager.IpRuleStatus.getStatus(customIp.status)) - } - - builder.create().show() - } - private fun updateFlagIfAvailable(ip: CustomIp) { if (ip.wildcard) return - b.customIpFlag.text = - getFlag( - getCountryCode( - IPAddressString(ip.ipAddress).hostAddress.toInetAddress(), - context - ) - ) - } - - private fun toggleActionsUi() { - if (b.customIpToggleGroup.tag == 0) { - b.customIpToggleGroup.tag = 1 - b.customIpToggleGroup.visibility = View.VISIBLE - return + val inetAddr = try { + IPAddressString(ip.ipAddress).hostAddress.toInetAddress() + } catch (e: Exception) { + null // invalid ip } - b.customIpToggleGroup.tag = 0 - b.customIpToggleGroup.visibility = View.GONE + b.customIpFlag.text = getFlag(getCountryCode(inetAddr, context)) } private fun updateStatusUi(status: IpRulesManager.IpRuleStatus) { @@ -500,163 +384,67 @@ class CustomIpAdapter(private val context: Context, private val type: CustomRule fun update(ci: CustomIp) { customIp = ci + b.customIpCheckbox.isChecked = selectedItems.contains(customIp) + b.customIpCheckbox.visibility = if (isSelectionMode) View.VISIBLE else View.GONE + b.customIpLabelTv.text = context.getString( R.string.ci_ip_label, customIp.ipAddress, customIp.port.toString() ) - b.customIpToggleGroup.tag = 1 val status = findSelectedIpRule(customIp.status) ?: return - // decide whether to show bypass-universal or bypass-app rule - showBypassUi(ci.uid) - // whether to show the toggle group or not - toggleActionsUi() - // update toggle group button based on the status - updateToggleGroup(status) // update flag for the available ips updateFlagIfAvailable(customIp) // update status in desc and status flag (N/B/W) updateStatusUi(status) - b.customIpToggleGroup.addOnButtonCheckedListener(ipRulesGroupListener) - b.customIpEditIcon.setOnClickListener { showEditIpDialog(customIp) } - b.customIpExpandIcon.setOnClickListener { toggleActionsUi() } - - b.customIpContainer.setOnClickListener { toggleActionsUi() } - } + b.customIpExpandIcon.setOnClickListener { showBtmSheet() } - private fun showBypassUi(uid: Int) { - if (uid == UID_EVERYBODY) { - b.customIpTgBypassUniv.visibility = View.VISIBLE - b.customIpTgBypassApp.visibility = View.GONE - } else { - b.customIpTgBypassUniv.visibility = View.GONE - b.customIpTgBypassApp.visibility = View.VISIBLE - } - } - - private fun updateToggleGroup(id: IpRulesManager.IpRuleStatus) { - val t = getToggleBtnUiParams(id) - - when (id) { - IpRulesManager.IpRuleStatus.NONE -> { - b.customIpToggleGroup.check(b.customIpTgNoRule.id) - selectToggleBtnUi(b.customIpTgNoRule, t) - unselectToggleBtnUi(b.customIpTgBlock) - unselectToggleBtnUi(b.customIpTgBypassUniv) - unselectToggleBtnUi(b.customIpTgBypassApp) - } - IpRulesManager.IpRuleStatus.BLOCK -> { - b.customIpToggleGroup.check(b.customIpTgBlock.id) - selectToggleBtnUi(b.customIpTgBlock, t) - unselectToggleBtnUi(b.customIpTgNoRule) - unselectToggleBtnUi(b.customIpTgBypassUniv) - unselectToggleBtnUi(b.customIpTgBypassApp) - } - IpRulesManager.IpRuleStatus.BYPASS_UNIVERSAL -> { - b.customIpToggleGroup.check(b.customIpTgBypassUniv.id) - selectToggleBtnUi(b.customIpTgBypassUniv, t) - unselectToggleBtnUi(b.customIpTgBlock) - unselectToggleBtnUi(b.customIpTgNoRule) - unselectToggleBtnUi(b.customIpTgBypassApp) - } - IpRulesManager.IpRuleStatus.TRUST -> { - b.customIpToggleGroup.check(b.customIpTgBypassApp.id) - selectToggleBtnUi(b.customIpTgBypassApp, t) - unselectToggleBtnUi(b.customIpTgBlock) - unselectToggleBtnUi(b.customIpTgNoRule) - unselectToggleBtnUi(b.customIpTgBypassUniv) - } - } - } - - private val ipRulesGroupListener = - MaterialButtonToggleGroup.OnButtonCheckedListener { group, checkedId, isChecked -> - val b: MaterialButton = b.customIpToggleGroup.findViewById(checkedId) - - val statusId = findSelectedIpRule(getTag(b.tag)) - // delete button - if (statusId == null && isChecked) { - group.clearChecked() - showDialogForDelete(customIp) - return@OnButtonCheckedListener - } - - // invalid selection - if (statusId == null) { - return@OnButtonCheckedListener - } - - if (isChecked) { - val hasStatusChanged = customIp.status != statusId.id - if (hasStatusChanged) { - val t = getToggleBtnUiParams(statusId) - // update the toggle button - selectToggleBtnUi(b, t) - // update the status in desc and status flag (N/B/BU) - updateStatusUi(statusId) - - changeIpStatus(statusId, customIp) - } else { - // no-op - } + b.customIpContainer.setOnClickListener { + if (isSelectionMode) { + toggleSelection(customIp) } else { - unselectToggleBtnUi(b) + showBtmSheet() } } - // each button in the toggle group is associated with tag value. - // tag values are ids of the IpRulesManager.IpRuleStatus - private fun getTag(tag: Any): Int { - return tag.toString().toIntOrNull() ?: 0 + b.customIpContainer.setOnLongClickListener { + isSelectionMode = true + selectedItems.add(customIp) + notifyDataSetChanged() + true + } } - private fun showDialogForDelete(customIp: CustomIp) { - val builder = MaterialAlertDialogBuilder(context) - builder.setTitle(R.string.univ_firewall_dialog_title) - builder.setMessage(R.string.univ_firewall_dialog_message) - builder.setCancelable(true) - builder.setPositiveButton(context.getString(R.string.lbl_delete)) { _, _ -> - io { IpRulesManager.removeIpRule(customIp.uid, customIp.ipAddress, customIp.port) } - Utilities.showToastUiCentered( - context, - context.getString(R.string.univ_ip_delete_individual_toast, customIp.ipAddress), - Toast.LENGTH_SHORT - ) - } + private fun showBtmSheet() { + val bottomSheet = CustomIpRulesBtmSheet(customIp) + bottomSheet.show((context as CustomRulesActivity).supportFragmentManager, bottomSheet.tag) + } - builder.setNegativeButton(context.getString(R.string.lbl_cancel)) { _, _ -> - updateStatusUi(IpRulesManager.IpRuleStatus.getStatus(customIp.status)) + private fun toggleSelection(item: CustomIp) { + if (selectedItems.contains(item)) { + selectedItems.remove(item) + b.customIpCheckbox.isChecked = false + } else { + selectedItems.add(item) + b.customIpCheckbox.isChecked = true } - - builder.create().show() } private fun updateFlagIfAvailable(ip: CustomIp) { if (ip.wildcard) return - b.customIpFlag.text = - getFlag( - getCountryCode( - IPAddressString(ip.ipAddress).hostAddress.toInetAddress(), - context - ) - ) - } - - private fun toggleActionsUi() { - if (b.customIpToggleGroup.tag == 0) { - b.customIpToggleGroup.tag = 1 - b.customIpToggleGroup.visibility = View.VISIBLE - return + val inetAddr = try { + IPAddressString(ip.ipAddress).hostAddress.toInetAddress() + } catch (e: Exception) { + null // invalid ip } - b.customIpToggleGroup.tag = 0 - b.customIpToggleGroup.visibility = View.GONE + b.customIpFlag.text = getFlag(getCountryCode(inetAddr, context)) } private fun updateStatusUi(status: IpRulesManager.IpRuleStatus) { @@ -791,7 +579,7 @@ class CustomIpAdapter(private val context: Context, private val type: CustomRule dBind.daciFailureTextView.visibility = View.VISIBLE return@ui } - + Logger.i(LOG_TAG_UI, "$TAG ip: $ip, port: $port, status: $status") updateCustomIp(customIp, ip, port, status) } } @@ -804,7 +592,7 @@ class CustomIpAdapter(private val context: Context, private val type: CustomRule ) { if (ipString == null) return // invalid ip (ui error shown already) - io { IpRulesManager.replaceIpRule(prev, ipString, port, status) } + io { IpRulesManager.replaceIpRule(prev, ipString, port, status, "", "") } } private suspend fun ioCtx(f: suspend () -> Unit) { diff --git a/app/src/full/java/com/celzero/bravedns/adapter/DnsCryptEndpointAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/DnsCryptEndpointAdapter.kt index 580b25fe6..69077bffb 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/DnsCryptEndpointAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/DnsCryptEndpointAdapter.kt @@ -29,7 +29,6 @@ import androidx.lifecycle.lifecycleScope import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView -import backend.Backend import com.celzero.bravedns.R import com.celzero.bravedns.data.AppConfig import com.celzero.bravedns.database.DnsCryptEndpoint @@ -38,6 +37,7 @@ import com.celzero.bravedns.service.VpnController import com.celzero.bravedns.util.UIUtils import com.celzero.bravedns.util.UIUtils.clipboardCopy import com.celzero.bravedns.util.Utilities +import com.celzero.firestack.backend.Backend import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job diff --git a/app/src/full/java/com/celzero/bravedns/adapter/DnsCryptRelayEndpointAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/DnsCryptRelayEndpointAdapter.kt index 27469e68c..8bdee660e 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/DnsCryptRelayEndpointAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/DnsCryptRelayEndpointAdapter.kt @@ -27,7 +27,6 @@ import androidx.lifecycle.lifecycleScope import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView -import backend.Backend import com.celzero.bravedns.R import com.celzero.bravedns.data.AppConfig import com.celzero.bravedns.database.DnsCryptRelayEndpoint @@ -36,6 +35,7 @@ import com.celzero.bravedns.service.VpnController import com.celzero.bravedns.util.UIUtils import com.celzero.bravedns.util.UIUtils.clipboardCopy import com.celzero.bravedns.util.Utilities +import com.celzero.firestack.backend.Backend import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch diff --git a/app/src/full/java/com/celzero/bravedns/adapter/DnsLogAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/DnsLogAdapter.kt new file mode 100644 index 000000000..8a9ff9eb2 --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/adapter/DnsLogAdapter.kt @@ -0,0 +1,468 @@ +/* +Copyright 2020 RethinkDNS and its authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package com.celzero.bravedns.adapter + +import Logger +import Logger.LOG_TAG_DNS +import Logger.LOG_TAG_UI +import android.content.Context +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.celzero.firestack.backend.Backend +import com.bumptech.glide.Glide +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions.withCrossFade +import com.bumptech.glide.request.target.CustomViewTarget +import com.bumptech.glide.request.transition.DrawableCrossFadeFactory +import com.bumptech.glide.request.transition.Transition +import com.celzero.bravedns.R +import com.celzero.bravedns.adapter.DnsLogAdapter.DnsLogViewHolder +import com.celzero.bravedns.database.DnsLog +import com.celzero.bravedns.databinding.ListItemDnsLogBinding +import com.celzero.bravedns.glide.FavIconDownloader +import com.celzero.bravedns.net.doh.Transaction +import com.celzero.bravedns.service.ProxyManager +import com.celzero.bravedns.ui.bottomsheet.DnsBlocklistBottomSheet +import com.celzero.bravedns.util.Constants +import com.celzero.bravedns.util.Constants.Companion.MAX_ENDPOINT +import com.celzero.bravedns.util.UIUtils.fetchColor +import com.celzero.bravedns.util.Utilities.getDefaultIcon +import com.celzero.bravedns.util.Utilities.getIcon +import com.google.gson.Gson + +class DnsLogAdapter(val context: Context, val loadFavIcon: Boolean, val isRethinkDns: Boolean) : + PagingDataAdapter(DIFF_CALLBACK) { + + companion object { + private const val TAG = "DnsLogAdapter" + private val DIFF_CALLBACK = + object : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(prev: DnsLog, curr: DnsLog) = + prev.id == curr.id + + override fun areContentsTheSame(prev: DnsLog, curr: DnsLog) = + prev == curr + } + } + + override fun onBindViewHolder(holder: DnsLogViewHolder, position: Int) { + val log: DnsLog = getItem(position) ?: return + + holder.clear() + holder.update(log) + holder.setTag(log) + } + + override fun getItemViewType(position: Int): Int { + return R.layout.list_item_dns_log + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DnsLogViewHolder { + val binding = ListItemDnsLogBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return DnsLogViewHolder(binding) + } + + inner class DnsLogViewHolder(private val b: ListItemDnsLogBinding): RecyclerView.ViewHolder(b.root) { + fun clear() { + b.dnsWallTime.text = "" + b.dnsFlag.text = "" + b.dnsQuery.text = "" + b.dnsAppName.text = "" + b.dnsIps.text = "" + b.dnsAppIcon.setImageDrawable(null) + b.dnsTypeName.text = "" + b.dnsQueryType.text = "" + b.dnsUnicodeHint.text = "" + b.dnsStatusIndicator.visibility = View.INVISIBLE + b.dnsSummaryLl.visibility = View.GONE + } + + fun setTag(log: DnsLog?) { + if (log == null) return + + b.dnsWallTime.tag = log.time + b.root.tag = log.time + } + + fun update(log: DnsLog) { + displayTransactionDetails(log) + displayAppDetails(log) + displayLogEntryHint(log) + displayIcon(log) + displayUnicodeIfNeeded(log) + displayDnsType(log) + b.dnsParentLayout.setOnClickListener { openBottomSheet(log) } + } + + private fun openBottomSheet(log: DnsLog) { + if (context !is FragmentActivity) { + Logger.w(LOG_TAG_UI, "$TAG err opening dns log btm sheet, no ctx to activity") + return + } + + val bottomSheetFragment = DnsBlocklistBottomSheet() + val bundle = Bundle() + bundle.putString(DnsBlocklistBottomSheet.INSTANCE_STATE_DNSLOGS, Gson().toJson(log)) + bottomSheetFragment.arguments = bundle + bottomSheetFragment.show(context.supportFragmentManager, bottomSheetFragment.tag) + } + + + private fun displayLogEntryHint(log: DnsLog) { + if (log.isBlocked) { + b.dnsStatusIndicator.visibility = View.VISIBLE + b.dnsStatusIndicator.setBackgroundColor( + ContextCompat.getColor(context, R.color.colorRed_A400) + ) + } else if (determineMaybeBlocked(log)) { + b.dnsStatusIndicator.visibility = View.VISIBLE + val color = fetchColor(context, R.attr.chipTextNeutral) + b.dnsStatusIndicator.setBackgroundColor(color) + } else { + b.dnsStatusIndicator.visibility = View.INVISIBLE + } + } + + private fun determineMaybeBlocked(log: DnsLog): Boolean { + return log.upstreamBlock || log.blockLists.isNotEmpty() + } + + private fun displayTransactionDetails(log: DnsLog) { + b.dnsWallTime.text = log.wallTime() + + b.dnsQuery.text = log.queryStr + b.dnsIps.text = log.responseIps.split(",").firstOrNull() ?: "" + b.dnsIps.visibility = View.VISIBLE + // marquee is not working for the textview, hence the workaround. + b.dnsIps.isSelected = true + + b.dnsLatency.text = context.getString(R.string.dns_query_latency, log.latency.toString()) + b.dnsQueryType.text = log.typeName + } + + private fun displayUnicodeIfNeeded(log: DnsLog) { + // rtt -> show rocket if less than 20ms, treat it as rtt + if (isRoundTripShorter(log.latency, log.isBlocked)) { + b.dnsUnicodeHint.text = + context.getString( + R.string.ci_desc, + b.dnsUnicodeHint.text, + context.getString(R.string.symbol_rocket) + ) + } + // bunny in case rpid as present, key in case of proxy + // bunny and key indicate conn is proxied, so its enough to show one of them + if (containsRelayProxy(log.relayIP)) { + b.dnsUnicodeHint.text = + context.getString( + R.string.ci_desc, + b.dnsUnicodeHint.text, + context.getString(R.string.symbol_bunny) + ) + } else if (isConnectionProxied(log.resolver)) { + b.dnsUnicodeHint.text = + context.getString( + R.string.ci_desc, + b.dnsUnicodeHint.text, + context.getString(R.string.symbol_key) + ) + } + + // show star if RethinkDNS or RPN is used + if (isRethinkUsed(log)) { + b.dnsUnicodeHint.text = + context.getString( + R.string.ci_desc, + b.dnsUnicodeHint.text, + getRethinkUnicode(log) + ) + } else if (isInternalResolverUsed(log)) { + // show duck icon in case of system or goos transport + b.dnsUnicodeHint.text = + context.getString( + R.string.ci_desc, + b.dnsUnicodeHint.text, + context.getString(R.string.symbol_duck) + ) + } else if (containsMultipleIPs(log)) { + b.dnsUnicodeHint.text = + context.getString( + R.string.ci_desc, + b.dnsUnicodeHint.text, + context.getString(R.string.symbol_heavy) + ) + } + + if (b.dnsUnicodeHint.text.isEmpty() && b.dnsQueryType.text.isEmpty()) { + b.dnsSummaryLl.visibility = View.GONE + } else { + b.dnsSummaryLl.visibility = View.VISIBLE + } + } + + private fun isRoundTripShorter(rtt: Long, blocked: Boolean): Boolean { + return rtt in 1..10 && !blocked + } + + private fun containsRelayProxy(rpid: String): Boolean { + return rpid.isNotEmpty() + } + + private fun isConnectionProxied(proxy: String?): Boolean { + if (proxy.isNullOrEmpty()) return false + + return !ProxyManager.isIpnProxy(proxy) + } + + private fun containsMultipleIPs(log: DnsLog): Boolean { + return log.responseIps.split(",").size > 1 + } + + private fun isRethinkUsed(log: DnsLog): Boolean { + if (log.status != Transaction.Status.COMPLETE.name) { + return false + } + + // now the rethink dns is added as preferred in the backend, instead of separate + // id, so match it with Preferred and BlockFree + return if (isRethinkDns) { + (log.resolverId.contains(Backend.Preferred) || + log.resolverId.contains(Backend.BlockFree)) + } else { + false + } + } + + private fun isInternalResolverUsed(log: DnsLog): Boolean { + if (log.status != Transaction.Status.COMPLETE.name) { + return false + } + + return log.resolverId.contains(Backend.Goos) || log.resolverId.contains(Backend.Default) || + log.resolverId.contains(Backend.System) || log.resolverId.contains(Backend.Bootstrap) + } + + private fun getRethinkUnicode(log: DnsLog): String { + // resolver check for rethink dns is done before calling this method + if (log.relayIP.endsWith(Backend.RPN) || log.relayIP == Backend.Auto) return context.getString( + R.string.symbol_sparkle + ) + + return if (log.serverIP.contains(MAX_ENDPOINT)) { + context.getString(R.string.symbol_max) + } else { + context.getString(R.string.symbol_sky) + } + } + + private fun displayAppDetails(log: DnsLog) { + if (log.appName.isEmpty()) { + b.dnsAppName.text = context.getString(R.string.network_log_app_name_unknown).uppercase() + } else { + b.dnsAppName.text = log.appName + } + if (log.packageName.isEmpty() || log.packageName == Constants.EMPTY_PACKAGE_NAME) { + loadAppIcon(getDefaultIcon(context)) + } else { + loadAppIcon(getIcon(context, log.packageName)) + } + return + } + + private fun loadAppIcon(drawable: Drawable?) { + Glide.with(context) + .load(drawable) + .error(getDefaultIcon(context)) + .into(b.dnsAppIcon) + } + + private fun displayIcon(log: DnsLog) { + b.dnsFlag.text = log.flag + b.dnsFlag.visibility = View.VISIBLE + b.dnsFavIcon.visibility = View.GONE + if (!loadFavIcon || log.groundedQuery()) { + clearFavIcon() + return + } + + // no need to check in glide cache if the value is available in failed cache + if ( + FavIconDownloader.isUrlAvailableInFailedCache(log.queryStr.dropLast(1)) != null + ) { + hideFavIcon() + showFlag() + } else { + // Glide will cache the icons against the urls. To extract the fav icon from the + // cache, first verify that the cache is available with the next dns url. + // If it is not available then glide will throw an error, do the duckduckgo + // url check in that case. + displayNextDnsFavIcon(log) + } + } + + private fun displayDnsType(log: DnsLog) { + val type = Transaction.TransportType.fromOrdinal(log.dnsType) + when (type) { + Transaction.TransportType.DOH -> { + if (isRethinkDns && isRethinkUsed(log)) { + b.dnsTypeName.text = context.getString(R.string.lbl_rdns) + } else { + b.dnsTypeName.text = context.getString(R.string.other_dns_list_tab1) + } + } + Transaction.TransportType.DNS_CRYPT -> { + b.dnsTypeName.text = context.getString(R.string.lbl_dc_abbr) + } + Transaction.TransportType.DNS_PROXY -> { + b.dnsTypeName.text = context.getString(R.string.lbl_dp) + } + Transaction.TransportType.DOT -> { + b.dnsTypeName.text = context.getString(R.string.lbl_dot) + } + Transaction.TransportType.ODOH -> { + b.dnsTypeName.text = context.getString(R.string.lbl_odoh) + } + } + } + + private fun clearFavIcon() { + Glide.with(context.applicationContext).clear(b.dnsFavIcon) + } + + private fun displayNextDnsFavIcon(log: DnsLog) { + val trim = log.queryStr.dropLastWhile { it == '.' } + // url to check if the icon is cached from nextdns + val nextDnsUrl = FavIconDownloader.constructFavIcoUrlNextDns(trim) + // url to check if the icon is cached from duckduckgo + val duckduckGoUrl = FavIconDownloader.constructFavUrlDuckDuckGo(trim) + // subdomain to check if the icon is cached from duckduckgo + val duckduckgoDomainURL = FavIconDownloader.getDomainUrlFromFdqnDuckduckgo(trim) + try { + val factory = DrawableCrossFadeFactory.Builder().setCrossFadeEnabled(true).build() + Glide.with(context.applicationContext) + .load(nextDnsUrl) + .onlyRetrieveFromCache(true) + .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) + .error( + // on error, check if the icon is stored in the name of duckduckgo url + displayDuckduckgoFavIcon(duckduckGoUrl, duckduckgoDomainURL) + ) + .transition(withCrossFade(factory)) + .into( + object : CustomViewTarget(b.dnsFavIcon) { + override fun onLoadFailed(errorDrawable: Drawable?) { + showFlag() + hideFavIcon() + } + + override fun onResourceReady( + resource: Drawable, + transition: Transition? + ) { + hideFlag() + showFavIcon(resource) + } + + override fun onResourceCleared(placeholder: Drawable?) { + hideFavIcon() + showFlag() + } + } + ) + } catch (ignored: Exception) { + Logger.d(LOG_TAG_DNS, "Error loading icon, load flag instead") + displayDuckduckgoFavIcon(duckduckGoUrl, duckduckgoDomainURL) + } + } + + /** + * Loads the fav icons from the cache, the icons are cached by favIconDownloader. On + * failure, will check if there is a icon for top level domain is available in cache. Else, + * will show the Flag. + * + * This method will be executed only when show fav icon setting is turned on. + */ + private fun displayDuckduckgoFavIcon(url: String, subDomainURL: String) { + try { + val factory = DrawableCrossFadeFactory.Builder().setCrossFadeEnabled(true).build() + Glide.with(context.applicationContext) + .load(url) + .onlyRetrieveFromCache(true) + .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) + .error( + Glide.with(context.applicationContext) + .load(subDomainURL) + .onlyRetrieveFromCache(true) + ) + .transition(withCrossFade(factory)) + .into( + object : CustomViewTarget(b.dnsFavIcon) { + override fun onLoadFailed(errorDrawable: Drawable?) { + showFlag() + hideFavIcon() + } + + override fun onResourceReady( + resource: Drawable, + transition: Transition? + ) { + hideFlag() + showFavIcon(resource) + } + + override fun onResourceCleared(placeholder: Drawable?) { + hideFavIcon() + showFlag() + } + } + ) + } catch (ignored: Exception) { + Logger.d(LOG_TAG_DNS, "$TAG err loading icon, load flag instead") + showFlag() + hideFavIcon() + } + } + + private fun showFavIcon(drawable: Drawable) { + b.dnsFavIcon.visibility = View.VISIBLE + b.dnsFavIcon.setImageDrawable(drawable) + } + + private fun hideFavIcon() { + b.dnsFavIcon.visibility = View.GONE + b.dnsFavIcon.setImageDrawable(null) + } + + private fun showFlag() { + b.dnsFlag.visibility = View.VISIBLE + } + + private fun hideFlag() { + b.dnsFlag.visibility = View.GONE + } + } +} diff --git a/app/src/full/java/com/celzero/bravedns/adapter/DnsQueryAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/DnsQueryAdapter.kt deleted file mode 100644 index 3fc929764..000000000 --- a/app/src/full/java/com/celzero/bravedns/adapter/DnsQueryAdapter.kt +++ /dev/null @@ -1,285 +0,0 @@ -/* -Copyright 2020 RethinkDNS and its authors - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - -https://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package com.celzero.bravedns.adapter - -import Logger -import Logger.LOG_TAG_DNS -import Logger.LOG_TAG_UI -import android.content.Context -import android.graphics.drawable.Drawable -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import androidx.core.content.ContextCompat -import androidx.fragment.app.FragmentActivity -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import com.bumptech.glide.Glide -import com.bumptech.glide.load.engine.DiskCacheStrategy -import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions.withCrossFade -import com.bumptech.glide.request.target.CustomViewTarget -import com.bumptech.glide.request.transition.DrawableCrossFadeFactory -import com.bumptech.glide.request.transition.Transition -import com.celzero.bravedns.R -import com.celzero.bravedns.database.DnsLog -import com.celzero.bravedns.databinding.TransactionRowBinding -import com.celzero.bravedns.glide.FavIconDownloader -import com.celzero.bravedns.ui.bottomsheet.DnsBlocklistBottomSheet -import com.celzero.bravedns.util.UIUtils.fetchColor -import com.google.gson.Gson - -class DnsQueryAdapter(val context: Context, val loadFavIcon: Boolean) : - PagingDataAdapter(DIFF_CALLBACK) { - - companion object { - const val TYPE_TRANSACTION: Int = 1 - private val DIFF_CALLBACK = - object : DiffUtil.ItemCallback() { - - override fun areItemsTheSame(oldConnection: DnsLog, newConnection: DnsLog) = - oldConnection.id == newConnection.id - - override fun areContentsTheSame(oldConnection: DnsLog, newConnection: DnsLog) = - oldConnection == newConnection - } - } - - override fun onBindViewHolder(holder: TransactionViewHolder, position: Int) { - val dnsLog: DnsLog = getItem(position) ?: return - - holder.update(dnsLog) - holder.setTag(dnsLog) - } - - override fun getItemViewType(position: Int): Int { - return TYPE_TRANSACTION - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TransactionViewHolder { - val itemBinding = - TransactionRowBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return TransactionViewHolder(itemBinding) - } - - inner class TransactionViewHolder(private val b: TransactionRowBinding) : - RecyclerView.ViewHolder(b.root) { - fun update(dnsLog: DnsLog?) { - if (dnsLog == null) return - - displayDetails(dnsLog) - displayLogEntryHint(dnsLog) - displayIcon(dnsLog) - - b.root.setOnClickListener { openBottomSheet(dnsLog) } - } - - fun setTag(dnsLog: DnsLog?) { - if (dnsLog == null) return - - b.responseTime.tag = dnsLog.time - b.root.tag = dnsLog.time - } - - private fun displayLogEntryHint(dnsLog: DnsLog) { - // TODO: make the entry as maybe blocked if there is a universal rule blocking the - // domain / ip - if (dnsLog.isBlocked) { - b.queryLogIndicator.visibility = View.VISIBLE - b.queryLogIndicator.setBackgroundColor( - ContextCompat.getColor(context, R.color.colorRed_A400) - ) - } else if (determineMaybeBlocked(dnsLog)) { - b.queryLogIndicator.visibility = View.VISIBLE - val color = fetchColor(context, R.attr.chipTextNeutral) - b.queryLogIndicator.setBackgroundColor(color) - } else { - b.queryLogIndicator.visibility = View.INVISIBLE - } - } - - private fun determineMaybeBlocked(dnsLog: DnsLog): Boolean { - return dnsLog.upstreamBlock || dnsLog.blockLists.isNotEmpty() - } - - private fun displayIcon(dnsLog: DnsLog) { - b.flag.text = dnsLog.flag - b.flag.visibility = View.VISIBLE - b.favIcon.visibility = View.GONE - if (!loadFavIcon || dnsLog.groundedQuery()) { - clearFavIcon() - return - } - - // no need to check in glide cache if the value is available in failed cache - if ( - FavIconDownloader.isUrlAvailableInFailedCache(dnsLog.queryStr.dropLast(1)) != null - ) { - hideFavIcon() - showFlag() - } else { - // Glide will cache the icons against the urls. To extract the fav icon from the - // cache, first verify that the cache is available with the next dns url. - // If it is not available then glide will throw an error, do the duckduckgo - // url check in that case. - displayNextDnsFavIcon(dnsLog) - } - } - - private fun clearFavIcon() { - Glide.with(context.applicationContext).clear(b.favIcon) - } - - private fun displayDetails(dnsLog: DnsLog) { - b.responseTime.text = dnsLog.wallTime() - b.fqdn.text = dnsLog.queryStr - - b.latencyVal.text = - context.getString(R.string.dns_query_latency, dnsLog.latency.toString()) - } - - private fun openBottomSheet(dnsLog: DnsLog) { - if (context !is FragmentActivity) { - Logger.w( - LOG_TAG_UI, - "Can not open bottom sheet. Context is not attached to activity" - ) - return - } - - val bottomSheetFragment = DnsBlocklistBottomSheet() - val bundle = Bundle() - bundle.putString(DnsBlocklistBottomSheet.INSTANCE_STATE_DNSLOGS, Gson().toJson(dnsLog)) - bottomSheetFragment.arguments = bundle - bottomSheetFragment.show(context.supportFragmentManager, bottomSheetFragment.tag) - } - - private fun displayNextDnsFavIcon(dnsLog: DnsLog) { - val trim = dnsLog.queryStr.dropLastWhile { it == '.' } - // url to check if the icon is cached from nextdns - val nextDnsUrl = FavIconDownloader.constructFavIcoUrlNextDns(trim) - // url to check if the icon is cached from duckduckgo - val duckduckGoUrl = FavIconDownloader.constructFavUrlDuckDuckGo(trim) - // subdomain to check if the icon is cached from duckduckgo - val duckduckgoDomainURL = FavIconDownloader.getDomainUrlFromFdqnDuckduckgo(trim) - try { - val factory = DrawableCrossFadeFactory.Builder().setCrossFadeEnabled(true).build() - Glide.with(context.applicationContext) - .load(nextDnsUrl) - .onlyRetrieveFromCache(true) - .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) - .error( - // on error, check if the icon is stored in the name of duckduckgo url - displayDuckduckgoFavIcon(duckduckGoUrl, duckduckgoDomainURL) - ) - .transition(withCrossFade(factory)) - .into( - object : CustomViewTarget(b.favIcon) { - override fun onLoadFailed(errorDrawable: Drawable?) { - showFlag() - hideFavIcon() - } - - override fun onResourceReady( - resource: Drawable, - transition: Transition? - ) { - hideFlag() - showFavIcon(resource) - } - - override fun onResourceCleared(placeholder: Drawable?) { - hideFavIcon() - showFlag() - } - } - ) - } catch (e: Exception) { - Logger.d(LOG_TAG_DNS, "Error loading icon, load flag instead") - displayDuckduckgoFavIcon(duckduckGoUrl, duckduckgoDomainURL) - } - } - - /** - * Loads the fav icons from the cache, the icons are cached by favIconDownloader. On - * failure, will check if there is a icon for top level domain is available in cache. Else, - * will show the Flag. - * - * This method will be executed only when show fav icon setting is turned on. - */ - private fun displayDuckduckgoFavIcon(url: String, subDomainURL: String) { - try { - val factory = DrawableCrossFadeFactory.Builder().setCrossFadeEnabled(true).build() - Glide.with(context.applicationContext) - .load(url) - .onlyRetrieveFromCache(true) - .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) - .error( - Glide.with(context.applicationContext) - .load(subDomainURL) - .onlyRetrieveFromCache(true) - ) - .transition(withCrossFade(factory)) - .into( - object : CustomViewTarget(b.favIcon) { - override fun onLoadFailed(errorDrawable: Drawable?) { - showFlag() - hideFavIcon() - } - - override fun onResourceReady( - resource: Drawable, - transition: Transition? - ) { - hideFlag() - showFavIcon(resource) - } - - override fun onResourceCleared(placeholder: Drawable?) { - hideFavIcon() - showFlag() - } - } - ) - } catch (e: Exception) { - Logger.d(LOG_TAG_DNS, "Error loading icon, load flag instead") - showFlag() - hideFavIcon() - } - } - - private fun showFavIcon(drawable: Drawable) { - b.favIcon.visibility = View.VISIBLE - b.favIcon.setImageDrawable(drawable) - } - - private fun hideFavIcon() { - b.favIcon.visibility = View.GONE - b.favIcon.setImageDrawable(null) - } - - private fun showFlag() { - b.flag.visibility = View.VISIBLE - } - - private fun hideFlag() { - b.flag.visibility = View.GONE - } - } -} diff --git a/app/src/full/java/com/celzero/bravedns/adapter/DoTEndpointAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/DoTEndpointAdapter.kt index 99556e52f..a9c687f98 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/DoTEndpointAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/DoTEndpointAdapter.kt @@ -30,7 +30,7 @@ import androidx.lifecycle.lifecycleScope import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView -import backend.Backend +import com.celzero.firestack.backend.Backend import com.celzero.bravedns.R import com.celzero.bravedns.data.AppConfig import com.celzero.bravedns.database.DoTEndpoint diff --git a/app/src/full/java/com/celzero/bravedns/adapter/DohEndpointAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/DohEndpointAdapter.kt index f2b2f316f..2d74a967a 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/DohEndpointAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/DohEndpointAdapter.kt @@ -30,7 +30,7 @@ import androidx.lifecycle.lifecycleScope import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView -import backend.Backend +import com.celzero.firestack.backend.Backend import com.celzero.bravedns.R import com.celzero.bravedns.data.AppConfig import com.celzero.bravedns.database.DoHEndpoint @@ -53,6 +53,7 @@ class DohEndpointAdapter(private val context: Context, private val appConfig: Ap companion object { private const val ONE_SEC = 1000L + private const val TAG = "DohEndpointAdapter" private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { override fun areItemsTheSame( @@ -174,10 +175,7 @@ class DohEndpointAdapter(private val context: Context, private val appConfig: Ap } private fun updateConnection(endpoint: DoHEndpoint) { - Logger.d( - LOG_TAG_DNS, - "on doh change - ${endpoint.dohName}, ${endpoint.dohURL}, ${endpoint.isSelected}" - ) + Logger.d(LOG_TAG_DNS, "$TAG update doh; ${endpoint.dohName}, ${endpoint.dohURL}, ${endpoint.isSelected}") io { endpoint.isSelected = true appConfig.handleDoHChanges(endpoint) @@ -186,6 +184,7 @@ class DohEndpointAdapter(private val context: Context, private val appConfig: Ap private fun deleteEndpoint(id: Int) { io { + Logger.i(LOG_TAG_DNS, "$TAG delete endpoint; $id") appConfig.deleteDohEndpoint(id) uiCtx { Utilities.showToastUiCentered( @@ -207,12 +206,10 @@ class DohEndpointAdapter(private val context: Context, private val appConfig: Ap builder.setTitle(title) builder.setMessage(url + "\n\n" + getDnsDesc(message)) builder.setCancelable(true) - builder.setPositiveButton(context.getString(R.string.dns_info_positive)) { dialogInterface, - _ -> + builder.setPositiveButton(context.getString(R.string.dns_info_positive)) { dialogInterface, _ -> dialogInterface.dismiss() } - builder.setNeutralButton(context.getString(R.string.dns_info_neutral)) { _: DialogInterface, - _: Int -> + builder.setNeutralButton(context.getString(R.string.dns_info_neutral)) { _: DialogInterface, _: Int -> clipboardCopy(context, url, context.getString(R.string.copy_clipboard_label)) Utilities.showToastUiCentered( context, diff --git a/app/src/full/java/com/celzero/bravedns/adapter/DomainConnectionsAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/DomainConnectionsAdapter.kt new file mode 100644 index 000000000..b55751e6a --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/adapter/DomainConnectionsAdapter.kt @@ -0,0 +1,160 @@ +/* + * Copyright 2024 RethinkDNS and its authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.celzero.bravedns.adapter + +import android.content.Context +import android.content.Intent +import android.graphics.drawable.Drawable +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.celzero.bravedns.R +import com.celzero.bravedns.data.AppConnection +import com.celzero.bravedns.databinding.ListItemStatisticsSummaryBinding +import com.celzero.bravedns.service.FirewallManager +import com.celzero.bravedns.ui.activity.AppInfoActivity +import com.celzero.bravedns.ui.activity.DomainConnectionsActivity +import com.celzero.bravedns.util.UIUtils.getCountryNameFromFlag +import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.getFlag +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class DomainConnectionsAdapter(private val context: Context, private val type: DomainConnectionsActivity.InputType) : + PagingDataAdapter( + DIFF_CALLBACK + ) { + + companion object { + private val DIFF_CALLBACK = + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldConnection: AppConnection, + newConnection: AppConnection + ): Boolean { + return (oldConnection == newConnection) + } + + override fun areContentsTheSame( + oldConnection: AppConnection, + newConnection: AppConnection + ): Boolean { + return (oldConnection == newConnection) + } + } + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): DomainConnectionsViewHolder { + val itemBinding = + ListItemStatisticsSummaryBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return DomainConnectionsViewHolder(itemBinding) + } + + override fun onBindViewHolder(holder: DomainConnectionsViewHolder, position: Int) { + val appNetworkActivity = getItem(position) ?: return + holder.bind(appNetworkActivity) + } + + inner class DomainConnectionsViewHolder(private val b: ListItemStatisticsSummaryBinding) : + RecyclerView.ViewHolder(b.root) { + + fun bind(dc: AppConnection) { + io { + val appInfo = FirewallManager.getAppInfoByUid(dc.uid) + uiCtx { + if (dc.appOrDnsName.isNullOrEmpty()) { + b.ssDataUsage.text = appInfo?.appName ?: context.getString( + R.string.network_log_app_name_unnamed, + "(${dc.uid})" + ) + } else { + b.ssDataUsage.text = dc.appOrDnsName + } + b.ssIcon.visibility = View.VISIBLE + b.ssFlag.visibility = View.GONE + loadAppIcon( + Utilities.getIcon( + context, + appInfo?.packageName ?: "", + appInfo?.appName ?: "" + ) + ) + } + } + if (dc.downloadBytes == null || dc.uploadBytes == null) { + return + } + + val download = + context.getString( + R.string.symbol_download, + Utilities.humanReadableByteCount(dc.downloadBytes, true) + ) + val upload = + context.getString( + R.string.symbol_upload, + Utilities.humanReadableByteCount(dc.uploadBytes, true) + ) + val total = context.getString(R.string.two_argument, upload, download) + b.ssName.text = total + b.ssCount.text = dc.count.toString() + + b.ssProgress.visibility = View.GONE + + b.ssContainer.setOnClickListener { + val intent = Intent(context, AppInfoActivity::class.java) + intent.putExtra(AppInfoActivity.INTENT_UID, dc.uid) + context.startActivity(intent) + } + + } + + private fun loadAppIcon(drawable: Drawable?) { + ui { + Glide.with(context) + .load(drawable) + .error(Utilities.getDefaultIcon(context)) + .into(b.ssIcon) + } + } + } + + private fun io(f: suspend () -> Unit) { + (context as LifecycleOwner).lifecycleScope.launch(Dispatchers.IO) { f() } + } + + private fun ui(f: suspend () -> Unit) { + (context as LifecycleOwner).lifecycleScope.launch(Dispatchers.Main) { f() } + } + + private suspend fun uiCtx(f: suspend () -> Unit) { + withContext(Dispatchers.Main) { f() } + } +} \ No newline at end of file diff --git a/app/src/full/java/com/celzero/bravedns/adapter/FirewallAppListAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/FirewallAppListAdapter.kt index 3d748dfc4..94ddac755 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/FirewallAppListAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/FirewallAppListAdapter.kt @@ -20,13 +20,11 @@ import android.content.DialogInterface import android.content.Intent import android.content.pm.PackageManager import android.graphics.drawable.Drawable -import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.ArrayAdapter import android.widget.ImageView -import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat import androidx.lifecycle.LifecycleOwner @@ -40,25 +38,27 @@ import com.celzero.bravedns.database.AppInfo import com.celzero.bravedns.databinding.ListItemFirewallAppBinding import com.celzero.bravedns.service.FirewallManager import com.celzero.bravedns.service.FirewallManager.updateFirewallStatus +import com.celzero.bravedns.service.ProxyManager +import com.celzero.bravedns.service.ProxyManager.ID_NONE import com.celzero.bravedns.ui.activity.AppInfoActivity -import com.celzero.bravedns.ui.activity.AppInfoActivity.Companion.UID_INTENT_NAME -import com.celzero.bravedns.util.UIUtils +import com.celzero.bravedns.ui.activity.AppInfoActivity.Companion.INTENT_UID import com.celzero.bravedns.util.Utilities import com.celzero.bravedns.util.Utilities.getIcon import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView.SectionedAdapter +import java.util.concurrent.TimeUnit import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.util.concurrent.TimeUnit class FirewallAppListAdapter( private val context: Context, private val lifecycleOwner: LifecycleOwner -) : PagingDataAdapter(DIFF_CALLBACK) { +) : PagingDataAdapter(DIFF_CALLBACK), SectionedAdapter { private val packageManager: PackageManager = context.packageManager - private val systemAppColor: Int by lazy { UIUtils.fetchColor(context, R.attr.textColorAccentBad) } - private val userAppColor: Int by lazy { UIUtils.fetchColor(context, R.attr.primaryTextColor) } + // private val systemAppColor: Int by lazy { UIUtils.fetchColor(context, R.attr.textColorAccentBad) } + // private val userAppColor: Int by lazy { UIUtils.fetchColor(context, R.attr.primaryTextColor) } companion object { private val DIFF_CALLBACK = @@ -74,9 +74,7 @@ class FirewallAppListAdapter( oldConnection: AppInfo, newConnection: AppInfo ): Boolean { - return (oldConnection.packageName == newConnection.packageName && - oldConnection.firewallStatus == newConnection.firewallStatus && - oldConnection.connectionStatus == newConnection.connectionStatus) + return oldConnection == newConnection } } } @@ -106,21 +104,25 @@ class FirewallAppListAdapter( val connStatus = FirewallManager.connectionStatus(appInfo.uid) uiCtx { b.firewallAppLabelTv.text = appInfo.appName - if (appInfo.isSystemApp) { + // setting the appname with different color for system and user apps + // causes conflict with the firewall status like blocked and isolated + // so removing the color change for now + /* if (appInfo.isSystemApp) { b.firewallAppLabelTv.setTextColor(systemAppColor) } else { b.firewallAppLabelTv.setTextColor(userAppColor) - } + } */ + b.firewallAppToggleOther.text = getFirewallText(appStatus, connStatus) + displayIcon( + getIcon(context, appInfo.packageName, appInfo.appName), b.firewallAppIconIv) + // set the alpha based on internet permission if (appInfo.hasInternetPermission(packageManager)) { b.firewallAppLabelTv.alpha = 1f + b.firewallAppIconIv.alpha = 1f } else { - b.firewallAppLabelTv.alpha = 0.6f + b.firewallAppLabelTv.alpha = 0.4f + b.firewallAppIconIv.alpha = 0.4f } - b.firewallAppToggleOther.text = getFirewallText(appStatus, connStatus) - displayIcon( - getIcon(context, appInfo.packageName, appInfo.appName), - b.firewallAppIconIv - ) if (appInfo.packageName == context.packageName) { b.firewallAppToggleWifi.visibility = View.GONE b.firewallAppToggleMobileData.visibility = View.GONE @@ -131,12 +133,43 @@ class FirewallAppListAdapter( b.firewallAppToggleWifi.visibility = View.VISIBLE b.firewallAppToggleMobileData.visibility = View.VISIBLE + // strike through the app name if the app is tombstoned + if (appInfo.tombstoneTs > 0) { + b.firewallAppLabelTv.paint.isStrikeThruText = true + b.firewallAppLabelTv.alpha = 0.4f + b.firewallAppIconIv.alpha = 0.4f + } else { + b.firewallAppLabelTv.paint.isStrikeThruText = false + } displayConnectionStatus(appStatus, connStatus) - showAppHint(b.firewallAppStatusIndicator, appInfo) + displayDataUsage(appInfo) + maybeDisplayProxyStatus(appInfo) } } } + private fun displayDataUsage(appInfo: AppInfo) { + val u = Utilities.humanReadableByteCount(appInfo.uploadBytes, true) + val uploadBytes = context.getString(R.string.symbol_upload, u) + val d = Utilities.humanReadableByteCount(appInfo.downloadBytes, true) + val downloadBytes = context.getString(R.string.symbol_download, d) + b.firewallAppDataUsage.text = + context.getString(R.string.two_argument, uploadBytes, downloadBytes) + } + + private fun maybeDisplayProxyStatus(appInfo: AppInfo) { + if (appInfo.isProxyExcluded) { + return + } + + // show key icon in drawable right of b.firewallAppDataUsage + val proxy = ProxyManager.getProxyIdForApp(appInfo.uid) + if (proxy.isEmpty() || proxy == ID_NONE) { + return + } + b.firewallAppLabelTv.append(context.getString(R.string.symbol_key)) + } + private fun getFirewallText( aStat: FirewallManager.FirewallStatus, cStat: FirewallManager.ConnectionStatus @@ -216,96 +249,32 @@ class FirewallAppListAdapter( private fun showMobileDataDisabled() { b.firewallAppToggleMobileData.setImageDrawable( - ContextCompat.getDrawable(context, R.drawable.ic_firewall_data_off) - ) + ContextCompat.getDrawable(context, R.drawable.ic_firewall_data_off)) } private fun showMobileDataEnabled() { b.firewallAppToggleMobileData.setImageDrawable( - ContextCompat.getDrawable(context, R.drawable.ic_firewall_data_on) - ) + ContextCompat.getDrawable(context, R.drawable.ic_firewall_data_on)) } private fun showWifiDisabled() { b.firewallAppToggleWifi.setImageDrawable( - ContextCompat.getDrawable(context, R.drawable.ic_firewall_wifi_off) - ) + ContextCompat.getDrawable(context, R.drawable.ic_firewall_wifi_off)) } private fun showWifiEnabled() { b.firewallAppToggleWifi.setImageDrawable( - ContextCompat.getDrawable(context, R.drawable.ic_firewall_wifi_on) - ) + ContextCompat.getDrawable(context, R.drawable.ic_firewall_wifi_on)) } private fun showMobileDataUnused() { b.firewallAppToggleMobileData.setImageDrawable( - ContextCompat.getDrawable(context, R.drawable.ic_firewall_data_on_grey) - ) + ContextCompat.getDrawable(context, R.drawable.ic_firewall_data_on_grey)) } private fun showWifiUnused() { b.firewallAppToggleWifi.setImageDrawable( - ContextCompat.getDrawable(context, R.drawable.ic_firewall_wifi_on_grey) - ) - } - - private fun showAppHint(mIconIndicator: TextView, appInfo: AppInfo) { - io { - val connStatus = FirewallManager.connectionStatus(appInfo.uid) - val appStatus = FirewallManager.appStatus(appInfo.uid) - uiCtx { - when (appStatus) { - FirewallManager.FirewallStatus.NONE -> { - when (connStatus) { - FirewallManager.ConnectionStatus.ALLOW -> { - mIconIndicator.setBackgroundColor( - context.getColor(R.color.colorGreen_900) - ) - } - FirewallManager.ConnectionStatus.METERED -> { - mIconIndicator.setBackgroundColor( - context.getColor(R.color.colorAmber_900) - ) - } - FirewallManager.ConnectionStatus.UNMETERED -> { - mIconIndicator.setBackgroundColor( - context.getColor(R.color.colorAmber_900) - ) - } - FirewallManager.ConnectionStatus.BOTH -> { - mIconIndicator.setBackgroundColor( - context.getColor(R.color.colorAmber_900) - ) - } - } - } - FirewallManager.FirewallStatus.EXCLUDE -> { - mIconIndicator.setBackgroundColor( - context.getColor(R.color.primaryLightColorText) - ) - } - FirewallManager.FirewallStatus.BYPASS_UNIVERSAL -> { - mIconIndicator.setBackgroundColor( - context.getColor(R.color.primaryLightColorText) - ) - } - FirewallManager.FirewallStatus.BYPASS_DNS_FIREWALL -> { - mIconIndicator.setBackgroundColor( - context.getColor(R.color.primaryLightColorText) - ) - } - FirewallManager.FirewallStatus.ISOLATE -> { - mIconIndicator.setBackgroundColor( - context.getColor(R.color.colorAmber_900) - ) - } - FirewallManager.FirewallStatus.UNTRACKED -> { - /* no-op */ - } - } - } - } + ContextCompat.getDrawable(context, R.drawable.ic_firewall_wifi_on_grey)) } private fun displayIcon(drawable: Drawable?, mIconImageView: ImageView) { @@ -377,29 +346,25 @@ class FirewallAppListAdapter( updateFirewallStatus( appInfo.uid, FirewallManager.FirewallStatus.NONE, - FirewallManager.ConnectionStatus.ALLOW - ) + FirewallManager.ConnectionStatus.ALLOW) } FirewallManager.ConnectionStatus.UNMETERED -> { updateFirewallStatus( appInfo.uid, FirewallManager.FirewallStatus.NONE, - FirewallManager.ConnectionStatus.BOTH - ) + FirewallManager.ConnectionStatus.BOTH) } FirewallManager.ConnectionStatus.BOTH -> { updateFirewallStatus( appInfo.uid, FirewallManager.FirewallStatus.NONE, - FirewallManager.ConnectionStatus.UNMETERED - ) + FirewallManager.ConnectionStatus.UNMETERED) } FirewallManager.ConnectionStatus.ALLOW -> { updateFirewallStatus( appInfo.uid, FirewallManager.FirewallStatus.NONE, - FirewallManager.ConnectionStatus.METERED - ) + FirewallManager.ConnectionStatus.METERED) } } } @@ -416,36 +381,32 @@ class FirewallAppListAdapter( updateFirewallStatus( appInfo.uid, FirewallManager.FirewallStatus.NONE, - FirewallManager.ConnectionStatus.BOTH - ) + FirewallManager.ConnectionStatus.BOTH) } FirewallManager.ConnectionStatus.UNMETERED -> { updateFirewallStatus( appInfo.uid, FirewallManager.FirewallStatus.NONE, - FirewallManager.ConnectionStatus.ALLOW - ) + FirewallManager.ConnectionStatus.ALLOW) } FirewallManager.ConnectionStatus.BOTH -> { updateFirewallStatus( appInfo.uid, FirewallManager.FirewallStatus.NONE, - FirewallManager.ConnectionStatus.METERED - ) + FirewallManager.ConnectionStatus.METERED) } FirewallManager.ConnectionStatus.ALLOW -> { updateFirewallStatus( appInfo.uid, FirewallManager.FirewallStatus.NONE, - FirewallManager.ConnectionStatus.UNMETERED - ) + FirewallManager.ConnectionStatus.UNMETERED) } } } private fun openAppDetailActivity(uid: Int) { val intent = Intent(context, AppInfoActivity::class.java) - intent.putExtra(UID_INTENT_NAME, uid) + intent.putExtra(INTENT_UID, uid) context.startActivity(intent) } @@ -461,8 +422,8 @@ class FirewallAppListAdapter( builderSingle.setIcon(R.drawable.ic_firewall_block_grey) val count = packageList.count() builderSingle.setTitle( - context.getString(R.string.ctbs_block_other_apps, appInfo.appName, count.toString()) - ) + context.getString( + R.string.ctbs_block_other_apps, appInfo.appName, count.toString())) val arrayAdapter = ArrayAdapter(context, android.R.layout.simple_list_item_activated_1) @@ -518,4 +479,9 @@ class FirewallAppListAdapter( private suspend fun ioCtx(f: suspend () -> Unit) { withContext(Dispatchers.IO) { f() } } + + override fun getSectionName(position: Int): String { + val appInfo = getItem(position) ?: return "" + return appInfo.appName.substring(0, 1) + } } diff --git a/app/src/full/java/com/celzero/bravedns/adapter/LocalAdvancedViewAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/LocalAdvancedViewAdapter.kt index bf9b51094..4818c3782 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/LocalAdvancedViewAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/LocalAdvancedViewAdapter.kt @@ -54,8 +54,7 @@ class LocalAdvancedViewAdapter(val context: Context) : oldConnection: RethinkLocalFileTag, newConnection: RethinkLocalFileTag ): Boolean { - return (oldConnection.value == newConnection.value && - oldConnection.isSelected == newConnection.isSelected) + return oldConnection == newConnection } } } diff --git a/app/src/full/java/com/celzero/bravedns/adapter/LocalSimpleViewAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/LocalSimpleViewAdapter.kt index e29a8fd63..5d4dea1e7 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/LocalSimpleViewAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/LocalSimpleViewAdapter.kt @@ -55,8 +55,7 @@ class LocalSimpleViewAdapter(val context: Context) : oldConnection: LocalBlocklistPacksMap, newConnection: LocalBlocklistPacksMap ): Boolean { - return (oldConnection.pack == newConnection.pack && - oldConnection.level == newConnection.level) + return oldConnection == newConnection } } } diff --git a/app/src/full/java/com/celzero/bravedns/adapter/ODoHEndpointAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/ODoHEndpointAdapter.kt index e3f0d858e..67d9f9503 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/ODoHEndpointAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/ODoHEndpointAdapter.kt @@ -30,7 +30,7 @@ import androidx.lifecycle.lifecycleScope import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView -import backend.Backend +import com.celzero.firestack.backend.Backend import com.celzero.bravedns.R import com.celzero.bravedns.data.AppConfig import com.celzero.bravedns.database.ODoHEndpoint diff --git a/app/src/full/java/com/celzero/bravedns/adapter/OneWgConfigAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/OneWgConfigAdapter.kt index b01e08ab1..bf529d045 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/OneWgConfigAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/OneWgConfigAdapter.kt @@ -15,6 +15,7 @@ */ package com.celzero.bravedns.adapter +import Logger.LOG_TAG_PROXY import android.content.Context import android.content.Intent import android.text.format.DateUtils @@ -29,14 +30,20 @@ import androidx.lifecycle.lifecycleScope import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView -import backend.Backend -import backend.Stats +import com.celzero.firestack.backend.RouterStats import com.celzero.bravedns.R import com.celzero.bravedns.database.WgConfigFiles import com.celzero.bravedns.databinding.ListItemWgOneInterfaceBinding +import com.celzero.bravedns.net.doh.Transaction import com.celzero.bravedns.service.ProxyManager import com.celzero.bravedns.service.VpnController import com.celzero.bravedns.service.WireguardManager +import com.celzero.bravedns.service.WireguardManager.ERR_CODE_OTHER_WG_ACTIVE +import com.celzero.bravedns.service.WireguardManager.ERR_CODE_VPN_NOT_ACTIVE +import com.celzero.bravedns.service.WireguardManager.ERR_CODE_VPN_NOT_FULL +import com.celzero.bravedns.service.WireguardManager.ERR_CODE_WG_INVALID +import com.celzero.bravedns.service.WireguardManager.WG_HANDSHAKE_TIMEOUT +import com.celzero.bravedns.service.WireguardManager.WG_UPTIME_THRESHOLD import com.celzero.bravedns.ui.activity.WgConfigDetailActivity import com.celzero.bravedns.ui.activity.WgConfigDetailActivity.Companion.INTENT_EXTRA_WG_TYPE import com.celzero.bravedns.ui.activity.WgConfigEditorActivity.Companion.INTENT_EXTRA_WG_ID @@ -60,7 +67,7 @@ class OneWgConfigAdapter(private val context: Context, private val listener: Dns companion object { private const val ONE_SEC = 1500L - + private const val TAG = "OneWgConfigAdapter" private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { @@ -68,17 +75,14 @@ class OneWgConfigAdapter(private val context: Context, private val listener: Dns oldConnection: WgConfigFiles, newConnection: WgConfigFiles ): Boolean { - return (oldConnection == newConnection) + return oldConnection == newConnection } override fun areContentsTheSame( oldConnection: WgConfigFiles, newConnection: WgConfigFiles ): Boolean { - return (oldConnection.id == newConnection.id && - oldConnection.name == newConnection.name && - oldConnection.isActive == newConnection.isActive && - oldConnection.oneWireGuard == newConnection.oneWireGuard) + return oldConnection == newConnection } } } @@ -95,36 +99,43 @@ class OneWgConfigAdapter(private val context: Context, private val listener: Dns parent, false ) - lifecycleOwner = parent.findViewTreeLifecycleOwner() + if (lifecycleOwner == null) { + lifecycleOwner = parent.findViewTreeLifecycleOwner() + } return WgInterfaceViewHolder(itemBinding) } + override fun onViewDetachedFromWindow(holder: WgInterfaceViewHolder) { + super.onViewDetachedFromWindow(holder) + holder.cancelJobIfAny() + } + inner class WgInterfaceViewHolder(private val b: ListItemWgOneInterfaceBinding) : RecyclerView.ViewHolder(b.root) { - private var statusCheckJob: Job? = null + private var job: Job? = null fun update(config: WgConfigFiles) { - b.interfaceNameText.text = config.name - b.oneWgCheck.isChecked = config.isActive - io { - updateStatus(config) - } + b.interfaceNameText.text = config.name.take(12) + b.interfaceIdText.text = context.getString(R.string.single_argument_parenthesis, config.id.toString()) + val isWgActive = config.isActive && VpnController.hasTunnel() + b.oneWgCheck.isChecked = isWgActive setupClickListeners(config) - if (config.oneWireGuard) { + if (isWgActive) { keepStatusUpdated(config) } else { - b.interfaceDetailCard.strokeWidth = 0 - b.interfaceAppsCount.visibility = View.GONE - b.protocolInfoChipGroup.visibility = View.GONE - b.interfaceActiveLayout.visibility = View.GONE - b.oneWgCheck.isChecked = false - b.interfaceStatus.text = - context.getString(R.string.lbl_disabled).replaceFirstChar(Char::titlecase) + cancelJobIfAny() + disableInterface() + } + } + + fun cancelJobIfAny() { + if (job?.isActive == true) { + job?.cancel() } } private fun keepStatusUpdated(config: WgConfigFiles) { - statusCheckJob = io { + job = io { while (true) { updateStatus(config) delay(ONE_SEC) @@ -168,15 +179,21 @@ class OneWgConfigAdapter(private val context: Context, private val listener: Dns ?.currentState ?.isAtLeast(androidx.lifecycle.Lifecycle.State.STARTED) == false ) { - statusCheckJob?.cancel() + job?.cancel() + return + } + + if (config.isActive && !VpnController.hasTunnel()) { + disableInterface() return } val id = ProxyManager.ID_WG_BASE + config.id - val statusId = VpnController.getProxyStatusById(id) + val statusPair = VpnController.getProxyStatusById(id) val pair = VpnController.getSupportedIpVersion(id) val c = WireguardManager.getConfigById(config.id) val stats = VpnController.getProxyStats(id) + val dnsStatusId = VpnController.getDnsStatus(ProxyManager.ID_WG_BASE + config.id) val isSplitTunnel = if (c?.getPeers()?.isNotEmpty() == true) { VpnController.isSplitTunnelProxy(id, pair) @@ -184,70 +201,41 @@ class OneWgConfigAdapter(private val context: Context, private val listener: Dns false } uiCtx { - updateStatusUi(config, statusId, stats) + updateStatusUi(config, statusPair, dnsStatusId, stats) updateProtocolChip(pair) updateSplitTunnelChip(isSplitTunnel) } } - private fun updateStatusUi(config: WgConfigFiles, statusId: Long?, stats: Stats?) { - if (config.isActive) { + private fun isDnsError(statusId: Long?): Boolean { + if (statusId == null) return true + + val s = Transaction.Status.fromId(statusId) + return s == Transaction.Status.BAD_QUERY || s == Transaction.Status.BAD_RESPONSE || s == Transaction.Status.NO_RESPONSE || s == Transaction.Status.SEND_FAIL || s == Transaction.Status.CLIENT_ERROR || s == Transaction.Status.INTERNAL_ERROR || s == Transaction.Status.TRANSPORT_ERROR + } + + private fun updateStatusUi(config: WgConfigFiles, statusPair: Pair, dnsStatusId: Long?, stats: RouterStats?) { + if (config.isActive && VpnController.hasTunnel()) { b.interfaceDetailCard.strokeWidth = 2 b.oneWgCheck.isChecked = true b.interfaceAppsCount.visibility = View.VISIBLE b.interfaceAppsCount.text = context.getString(R.string.one_wg_apps_added) - var status: String - val handShakeTime = getHandshakeTime(stats) - if (statusId != null) { - var resId = UIUtils.getProxyStatusStringRes(statusId) - // change the color based on the status - if (statusId == Backend.TOK) { - if (stats?.lastOK == 0L) { - b.interfaceDetailCard.strokeColor = - fetchColor(context, R.attr.chipTextNeutral) - resId = R.string.status_waiting - } else { - b.interfaceDetailCard.strokeColor = - fetchColor(context, R.attr.accentGood) - } - } else if (statusId == Backend.TUP || statusId == Backend.TZZ) { - b.interfaceDetailCard.strokeColor = - fetchColor(context, R.attr.chipTextNeutral) + + if (dnsStatusId != null) { + // check for dns failure cases and update the UI + if (isDnsError(dnsStatusId)) { + b.interfaceDetailCard.strokeColor = fetchColor(context, R.attr.chipTextNegative) + b.interfaceStatus.text = + context.getString(R.string.status_failing).replaceFirstChar(Char::titlecase) } else { - b.interfaceDetailCard.strokeColor = - fetchColor(context, R.attr.chipTextNegative) - } - status = - if (stats?.lastOK == 0L) { - context.getString(resId).replaceFirstChar(Char::titlecase) - } else { - context.getString( - R.string.about_version_install_source, - context.getString(resId).replaceFirstChar(Char::titlecase), - handShakeTime - ) - } - - if ((statusId == Backend.TZZ || statusId == Backend.TNT) && stats != null) { - // for idle state, if lastOk is less than 30 sec, then show as connected - if ( - stats.lastOK != 0L && - System.currentTimeMillis() - stats.lastOK < - 30 * DateUtils.SECOND_IN_MILLIS - ) { - status = - context - .getString(R.string.dns_connected) - .replaceFirstChar(Char::titlecase) - } + // if dns status is not failing, then update the proxy status + updateProxyStatusUi(statusPair, stats) } } else { - b.interfaceDetailCard.strokeColor = fetchColor(context, R.attr.chipTextNegative) - b.interfaceDetailCard.strokeWidth = 2 - status = - context.getString(R.string.status_waiting).replaceFirstChar(Char::titlecase) + // in one wg mode, if dns status should be available, this is a fallback case + updateProxyStatusUi(statusPair, stats) } - b.interfaceStatus.text = status + b.interfaceActiveLayout.visibility = View.VISIBLE val rxtx = getRxTx(stats) val time = getUpTime(stats) @@ -265,19 +253,94 @@ class OneWgConfigAdapter(private val context: Context, private val listener: Dns } b.interfaceActiveRxTx.text = rxtx } else { - b.interfaceDetailCard.strokeWidth = 0 - b.interfaceAppsCount.visibility = View.GONE - b.oneWgCheck.isChecked = false - b.interfaceActiveLayout.visibility = View.GONE - b.interfaceStatus.text = - context.getString(R.string.lbl_disabled).replaceFirstChar(Char::titlecase) + disableInterface() + } + } + + private fun getStrokeColorForStatus(status: UIUtils.ProxyStatus?, stats: RouterStats?): Int{ + return when (status) { + UIUtils.ProxyStatus.TOK -> if (stats?.lastOK == 0L) R.attr.chipTextNeutral else R.attr.accentGood + UIUtils.ProxyStatus.TUP, UIUtils.ProxyStatus.TZZ -> R.attr.chipTextNeutral + else -> R.attr.chipTextNegative // TNT, TKO, TEND + } + } + + private fun getStatusText( + status: UIUtils.ProxyStatus?, + handshakeTime: String? = null, + stats: RouterStats?, + errMsg: String? = null + ): String { + if (status == null) { + val txt = if (errMsg != null) { + context.getString(R.string.status_waiting) + " ($errMsg)" + } else { + context.getString(R.string.status_waiting) + } + return txt.replaceFirstChar(Char::titlecase) + } + + val now = System.currentTimeMillis() + val lastOk = stats?.lastOK ?: 0L + val since = stats?.since ?: 0L + if (now - since > WG_UPTIME_THRESHOLD && lastOk == 0L) { + return context.getString(R.string.status_failing).replaceFirstChar(Char::titlecase) + } + + val baseText = context.getString(UIUtils.getProxyStatusStringRes(status.id)) + .replaceFirstChar(Char::titlecase) + + return if (stats?.lastOK != 0L && handshakeTime != null) { + context.getString(R.string.about_version_install_source, baseText, handshakeTime) + } else { + baseText + } + } + + private fun getIdleStatusText(status: UIUtils.ProxyStatus?, stats: RouterStats?): String { + if (status != UIUtils.ProxyStatus.TZZ && status != UIUtils.ProxyStatus.TNT) return "" + if (stats == null || stats.lastOK == 0L) return "" + if (System.currentTimeMillis() - stats.since >= WG_HANDSHAKE_TIMEOUT) return "" + + return context.getString(R.string.dns_connected).replaceFirstChar(Char::titlecase) + } + + private fun updateProxyStatusUi(statusPair: Pair, stats: RouterStats?) { + val status = + UIUtils.ProxyStatus.entries.find { it.id == statusPair.first } // Convert to enum + + val handshakeTime = getHandshakeTime(stats).toString() + + val strokeColor = getStrokeColorForStatus(status, stats) + b.interfaceDetailCard.strokeColor = fetchColor(context, strokeColor) + val statusText = getIdleStatusText(status, stats).ifEmpty { + getStatusText( + status, + handshakeTime, + stats, + statusPair.second + ) } + b.interfaceStatus.text = statusText } - private fun getUpTime(stats: Stats?): CharSequence { + private fun disableInterface() { + b.interfaceDetailCard.strokeWidth = 0 + b.protocolInfoChipGroup.visibility = View.GONE + b.interfaceAppsCount.visibility = View.GONE + b.oneWgCheck.isChecked = false + b.interfaceActiveLayout.visibility = View.GONE + b.interfaceStatus.text = + context.getString(R.string.lbl_disabled).replaceFirstChar(Char::titlecase) + } + + private fun getUpTime(stats: RouterStats?): CharSequence { if (stats == null) { return "" } + if (stats.since <= 0L) { + return "" + } val now = System.currentTimeMillis() // returns a string describing 'time' as a time relative to 'now' return DateUtils.getRelativeTimeSpanString( @@ -288,7 +351,7 @@ class OneWgConfigAdapter(private val context: Context, private val listener: Dns ) } - private fun getRxTx(stats: Stats?): String { + private fun getRxTx(stats: RouterStats?): String { if (stats == null) return "" val rx = context.getString( @@ -300,10 +363,10 @@ class OneWgConfigAdapter(private val context: Context, private val listener: Dns R.string.symbol_upload, Utilities.humanReadableByteCount(stats.tx, true) ) - return context.getString(R.string.two_argument_space, rx, tx) + return context.getString(R.string.two_argument_space, tx, rx) } - private fun getHandshakeTime(stats: Stats?): CharSequence { + private fun getHandshakeTime(stats: RouterStats?): CharSequence { if (stats == null) { return "" } @@ -327,35 +390,114 @@ class OneWgConfigAdapter(private val context: Context, private val listener: Dns val isChecked = b.oneWgCheck.isChecked io { if (isChecked) { - if (WireguardManager.canEnableConfig(config.toImmutable())) { - config.oneWireGuard = true - WireguardManager.updateOneWireGuardConfig(config.id, owg = true) - WireguardManager.enableConfig(config.toImmutable()) - uiCtx { listener.onDnsStatusChanged() } - } else { - uiCtx { - b.oneWgCheck.isChecked = false - Utilities.showToastUiCentered( - context, - context.getString(R.string.wireguard_enabled_failure), - Toast.LENGTH_LONG - ) - } - } + enableWgIfPossible(config) } else { - config.oneWireGuard = false - WireguardManager.updateOneWireGuardConfig(config.id, owg = false) - WireguardManager.disableConfig(config.toImmutable()) - uiCtx { - b.oneWgCheck.isChecked = false - listener.onDnsStatusChanged() - } + disableWgIfPossible(config) } } } } + private suspend fun enableWgIfPossible(config: WgConfigFiles) { + if (!VpnController.hasTunnel()) { + Logger.i(LOG_TAG_PROXY, "$TAG VPN not active, cannot enable WireGuard") + uiCtx { + Utilities.showToastUiCentered( + context, + ERR_CODE_VPN_NOT_ACTIVE + + context.getString(R.string.settings_socks5_vpn_disabled_error), + Toast.LENGTH_LONG + ) + // reset the check box + b.oneWgCheck.isChecked = false + } + return + } + + if (!WireguardManager.canEnableProxy()) { + Logger.i(LOG_TAG_PROXY, "not in DNS+Firewall mode, cannot enable WireGuard") + uiCtx { + // reset the check box + b.oneWgCheck.isChecked = false + Utilities.showToastUiCentered( + context, + ERR_CODE_VPN_NOT_FULL + + context.getString(R.string.wireguard_enabled_failure), + Toast.LENGTH_LONG + ) + } + return + } + + if (WireguardManager.isAnyOtherOneWgEnabled(config.id)) { + Logger.i(LOG_TAG_PROXY, "another WireGuard is already enabled") + uiCtx { + // reset the check box + b.oneWgCheck.isChecked = false + Utilities.showToastUiCentered( + context, + ERR_CODE_OTHER_WG_ACTIVE + + context.getString(R.string.wireguard_enabled_failure), + Toast.LENGTH_LONG + ) + } + return + } + + if (!WireguardManager.isValidConfig(config.id)) { + Logger.i(LOG_TAG_PROXY, "invalid WireGuard config") + uiCtx { + // reset the check box + b.oneWgCheck.isChecked = false + Utilities.showToastUiCentered( + context, + ERR_CODE_WG_INVALID + context.getString(R.string.wireguard_enabled_failure), + Toast.LENGTH_LONG + ) + } + return + } + + Logger.i(LOG_TAG_PROXY, "enabling WireGuard, id: ${config.id}") + WireguardManager.updateOneWireGuardConfig(config.id, owg = true) + config.oneWireGuard = true + WireguardManager.enableConfig(config.toImmutable()) + uiCtx { listener.onDnsStatusChanged() } + } + + private suspend fun disableWgIfPossible(config: WgConfigFiles) { + if (!VpnController.hasTunnel()) { + Logger.i(LOG_TAG_PROXY, "VPN not active, cannot disable WireGuard") + uiCtx { + // reset the check box + b.oneWgCheck.isChecked = true + Utilities.showToastUiCentered( + context, + ERR_CODE_VPN_NOT_ACTIVE + + context.getString(R.string.settings_socks5_vpn_disabled_error), + Toast.LENGTH_LONG + ) + } + return + } + + Logger.i(LOG_TAG_PROXY, "disabling WireGuard, id: ${config.id}") + WireguardManager.updateOneWireGuardConfig(config.id, owg = false) + config.oneWireGuard = false + WireguardManager.disableConfig(config.toImmutable()) + uiCtx { listener.onDnsStatusChanged() } + } + private fun launchConfigDetail(id: Int) { + if (!VpnController.hasTunnel()) { + Utilities.showToastUiCentered( + context, + context.getString(R.string.ssv_toast_start_rethink), + Toast.LENGTH_SHORT + ) + return + } + val intent = Intent(context, WgConfigDetailActivity::class.java) intent.putExtra(INTENT_EXTRA_WG_ID, id) intent.putExtra(INTENT_EXTRA_WG_TYPE, WgConfigDetailActivity.WgType.ONE_WG.value) diff --git a/app/src/full/java/com/celzero/bravedns/adapter/RemoteAdvancedViewAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/RemoteAdvancedViewAdapter.kt index a42b2793e..5ae3b1965 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/RemoteAdvancedViewAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/RemoteAdvancedViewAdapter.kt @@ -55,8 +55,7 @@ class RemoteAdvancedViewAdapter(val context: Context) : oldConnection: RethinkRemoteFileTag, newConnection: RethinkRemoteFileTag ): Boolean { - return (oldConnection.value == newConnection.value && - oldConnection.isSelected == newConnection.isSelected) + return oldConnection == newConnection } } } diff --git a/app/src/full/java/com/celzero/bravedns/adapter/RemoteSimpleViewAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/RemoteSimpleViewAdapter.kt index 89cd84d28..c65b0d5e7 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/RemoteSimpleViewAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/RemoteSimpleViewAdapter.kt @@ -55,8 +55,7 @@ class RemoteSimpleViewAdapter(val context: Context) : oldConnection: RemoteBlocklistPacksMap, newConnection: RemoteBlocklistPacksMap ): Boolean { - return (oldConnection.pack == newConnection.pack && - oldConnection.level == newConnection.level) + return oldConnection == newConnection } } } diff --git a/app/src/full/java/com/celzero/bravedns/adapter/RethinkEndpointAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/RethinkEndpointAdapter.kt index 5a397630c..80428d64b 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/RethinkEndpointAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/RethinkEndpointAdapter.kt @@ -31,7 +31,7 @@ import androidx.lifecycle.lifecycleScope import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView -import backend.Backend +import com.celzero.firestack.backend.Backend import com.celzero.bravedns.R import com.celzero.bravedns.data.AppConfig import com.celzero.bravedns.database.RethinkDnsEndpoint @@ -58,6 +58,7 @@ class RethinkEndpointAdapter(private val context: Context, private val appConfig companion object { private const val ONE_SEC = 1000L + private const val TAG = "RethinkEndpointAdapter" private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { override fun areItemsTheSame( @@ -190,7 +191,7 @@ class RethinkEndpointAdapter(private val context: Context, private val appConfig private fun updateConnection(endpoint: RethinkDnsEndpoint) { Logger.d( LOG_TAG_DNS, - "on rethink dns change - ${endpoint.name}, ${endpoint.url}, ${endpoint.isActive}" + "$TAG rdns update; ${endpoint.name}, ${endpoint.url}, ${endpoint.isActive}" ) io { @@ -205,18 +206,15 @@ class RethinkEndpointAdapter(private val context: Context, private val appConfig builder.setMessage(endpoint.url + "\n\n" + endpoint.desc) builder.setCancelable(true) if (endpoint.isEditable(context)) { - builder.setPositiveButton(context.getString(R.string.rt_edit_dialog_positive)) { _, - _ -> + builder.setPositiveButton(context.getString(R.string.rt_edit_dialog_positive)) { _, _ -> openEditConfiguration(endpoint) } } else { - builder.setPositiveButton(context.getString(R.string.dns_info_positive)) { dialogInterface, - _ -> + builder.setPositiveButton(context.getString(R.string.dns_info_positive)) { dialogInterface, _ -> dialogInterface.dismiss() } } - builder.setNeutralButton(context.getString(R.string.dns_info_neutral)) { _: DialogInterface, - _: Int -> + builder.setNeutralButton(context.getString(R.string.dns_info_neutral)) { _: DialogInterface, _: Int -> clipboardCopy( context, endpoint.url, diff --git a/app/src/full/java/com/celzero/bravedns/adapter/RethinkLogAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/RethinkLogAdapter.kt index 60d6f449e..747cf4623 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/RethinkLogAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/RethinkLogAdapter.kt @@ -35,7 +35,7 @@ import com.bumptech.glide.Glide import com.celzero.bravedns.R import com.celzero.bravedns.database.ConnectionTracker import com.celzero.bravedns.database.RethinkLog -import com.celzero.bravedns.databinding.ConnectionTransactionRowBinding +import com.celzero.bravedns.databinding.ListItemConnTrackBinding import com.celzero.bravedns.service.FirewallManager import com.celzero.bravedns.service.ProxyManager import com.celzero.bravedns.service.VpnController @@ -79,7 +79,7 @@ class RethinkLogAdapter(private val context: Context) : override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RethinkLogViewHolder { val itemBinding = - ConnectionTransactionRowBinding.inflate( + ListItemConnTrackBinding.inflate( LayoutInflater.from(parent.context), parent, false @@ -94,7 +94,7 @@ class RethinkLogAdapter(private val context: Context) : holder.setTag(log) } - inner class RethinkLogViewHolder(private val b: ConnectionTransactionRowBinding) : + inner class RethinkLogViewHolder(private val b: ListItemConnTrackBinding) : RecyclerView.ViewHolder(b.root) { fun update(log: RethinkLog) { diff --git a/app/src/full/java/com/celzero/bravedns/adapter/RpnCountriesAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/RpnCountriesAdapter.kt new file mode 100644 index 000000000..b040189ac --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/adapter/RpnCountriesAdapter.kt @@ -0,0 +1,119 @@ +/* + * Copyright 2023 RethinkDNS and its authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.celzero.bravedns.adapter + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.findViewTreeLifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.RecyclerView +import com.celzero.bravedns.R +import com.celzero.bravedns.databinding.ListItemRpnCountriesBinding +import com.celzero.bravedns.rpnproxy.RegionalWgConf +import com.celzero.bravedns.util.UIUtils.fetchColor +import com.celzero.bravedns.util.UIUtils.getCountryNameFromFlag +import com.celzero.bravedns.util.Utilities.getFlag +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class RpnCountriesAdapter(private val context: Context, private val countries: List, private val selectedCCs: Set) : + RecyclerView.Adapter() { + + private var lifecycleOwner: LifecycleOwner? = null + + override fun onBindViewHolder(holder: RpnCountriesViewHolder, position: Int) { + holder.update(countries[position]) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RpnCountriesViewHolder { + val itemBinding = + ListItemRpnCountriesBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + if (lifecycleOwner == null) { + lifecycleOwner = parent.findViewTreeLifecycleOwner() + } + return RpnCountriesViewHolder(itemBinding) + } + + override fun getItemCount(): Int { + return countries.size + } + + inner class RpnCountriesViewHolder(private val b: ListItemRpnCountriesBinding) : + RecyclerView.ViewHolder(b.root) { + + fun update(conf: RegionalWgConf) { + val flag = getFlag(conf.cc) + val ccName = conf.name.ifEmpty { getCountryNameFromFlag(flag) } + b.rpncNameText.text = ccName + b.rpncFlagText.text = flag + setupClickListeners(conf) + val isSelected = selectedCCs.contains(conf.cc) + if (isSelected) { + enableInterface() + } else { + disableInterface() + } + val strokeColor = getStrokeColorForStatus(isSelected) + b.rpncCard.strokeColor = fetchColor(context, strokeColor) + } + + private fun getStrokeColorForStatus(isActive: Boolean): Int{ + if (!isActive) return fetchColor(context, R.attr.chipTextNegative) + return fetchColor(context, R.attr.accentGood) + } + + private fun enableInterface() { + b.rpncCard.strokeWidth = 2 + b.rpncInfoChipGroup.visibility = View.VISIBLE + b.rpncCheck.isChecked = true + b.rpncActiveChip.visibility = View.VISIBLE + b.rpncActiveLayout.visibility = View.VISIBLE + b.rpncStatusText.text = + context.getString(R.string.lbl_active).replaceFirstChar(Char::titlecase) + } + + private fun disableInterface() { + b.rpncCard.strokeWidth = 0 + b.rpncInfoChipGroup.visibility = View.GONE + b.rpncCheck.isChecked = false + b.rpncActiveChip.visibility = View.GONE + b.rpncActiveLayout.visibility = View.GONE + b.rpncStatusText.text = + context.getString(R.string.lbl_disabled).replaceFirstChar(Char::titlecase) + } + + fun setupClickListeners(conf: RegionalWgConf) { + + } + } + + private suspend fun uiCtx(f: suspend () -> Unit) { + withContext(Dispatchers.Main) { f() } + } + + private fun io(f: suspend () -> Unit): Job? { + return lifecycleOwner?.lifecycleScope?.launch(Dispatchers.IO) { f() } + } +} diff --git a/app/src/full/java/com/celzero/bravedns/adapter/SummaryStatisticsAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/SummaryStatisticsAdapter.kt index 80214ad68..30c8d219b 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/SummaryStatisticsAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/SummaryStatisticsAdapter.kt @@ -45,13 +45,16 @@ import com.celzero.bravedns.glide.FavIconDownloader import com.celzero.bravedns.service.FirewallManager import com.celzero.bravedns.service.PersistentState import com.celzero.bravedns.ui.activity.AppInfoActivity +import com.celzero.bravedns.ui.activity.DomainConnectionsActivity import com.celzero.bravedns.ui.activity.NetworkLogsActivity import com.celzero.bravedns.ui.fragment.SummaryStatisticsFragment.SummaryStatisticsType import com.celzero.bravedns.util.Constants import com.celzero.bravedns.util.UIUtils.fetchToggleBtnColors import com.celzero.bravedns.util.UIUtils.getCountryNameFromFlag import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.getFlag import com.celzero.bravedns.util.Utilities.isAtleastN +import com.celzero.bravedns.viewmodel.SummaryStatisticsViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -68,22 +71,17 @@ class SummaryStatisticsAdapter( ) { private var maxValue: Int = 0 + private var timeCategory = SummaryStatisticsViewModel.TimeCategory.ONE_HOUR companion object { private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldConnection: AppConnection, - newConnection: AppConnection - ): Boolean { - return (oldConnection == newConnection) + override fun areItemsTheSame(old: AppConnection, new: AppConnection): Boolean { + return (old == new) } - override fun areContentsTheSame( - oldConnection: AppConnection, - newConnection: AppConnection - ): Boolean { - return (oldConnection == newConnection) + override fun areContentsTheSame(old: AppConnection, new: AppConnection): Boolean { + return (old == new) } } } @@ -102,8 +100,8 @@ class SummaryStatisticsAdapter( } override fun onBindViewHolder(holder: AppNetworkActivityViewHolder, position: Int) { - val appNetworkActivity = getItem(position) ?: return - holder.bind(appNetworkActivity) + val conn = getItem(position) ?: return + holder.bind(conn) } private fun calculatePercentage(c: Double): Int { @@ -119,6 +117,10 @@ class SummaryStatisticsAdapter( } } + fun setTimeCategory(timeCategory: SummaryStatisticsViewModel.TimeCategory) { + this.timeCategory = timeCategory + } + inner class AppNetworkActivityViewHolder( private val itemBinding: ListItemStatisticsSummaryBinding ) : RecyclerView.ViewHolder(itemBinding.root) { @@ -160,7 +162,7 @@ class SummaryStatisticsAdapter( R.string.symbol_upload, Utilities.humanReadableByteCount(appConnection.uploadBytes, true) ) - val total = context.getString(R.string.two_argument, download, upload) + val total = context.getString(R.string.two_argument, upload, download) itemBinding.ssDataUsage.text = total itemBinding.ssCount.text = appConnection.count.toString() } @@ -168,6 +170,22 @@ class SummaryStatisticsAdapter( private suspend fun setIcon(appConnection: AppConnection) { when (type) { + SummaryStatisticsType.TOP_ACTIVE_CONNS -> { + io { + val appInfo = FirewallManager.getAppInfoByUid(appConnection.uid) + uiCtx { + itemBinding.ssIcon.visibility = View.VISIBLE + itemBinding.ssFlag.visibility = View.GONE + loadAppIcon( + Utilities.getIcon( + context, + appInfo?.packageName ?: "", + appInfo?.appName ?: "" + ) + ) + } + } + } SummaryStatisticsType.MOST_CONNECTED_APPS -> { io { val appInfo = FirewallManager.getAppInfoByUid(appConnection.uid) @@ -200,6 +218,30 @@ class SummaryStatisticsAdapter( } } } + SummaryStatisticsType.MOST_CONNECTED_ASN -> { + uiCtx { + if (appConnection.flag.isNotEmpty()) { + val flag = getFlag(appConnection.flag) + itemBinding.ssFlag.text = flag + } else { + itemBinding.ssFlag.text = "--" + } + itemBinding.ssIcon.visibility = View.GONE + itemBinding.ssFlag.visibility = View.VISIBLE + } + } + SummaryStatisticsType.MOST_BLOCKED_ASN -> { + uiCtx { + if (appConnection.flag.isNotEmpty()) { + val flag = getFlag(appConnection.flag) + itemBinding.ssFlag.text = flag + } else { + itemBinding.ssFlag.text = "--" + } + itemBinding.ssIcon.visibility = View.GONE + itemBinding.ssFlag.visibility = View.VISIBLE + } + } SummaryStatisticsType.MOST_CONTACTED_DOMAINS -> { uiCtx { itemBinding.ssFlag.text = appConnection.flag @@ -252,18 +294,21 @@ class SummaryStatisticsAdapter( itemBinding.ssFlag.text = appConnection.flag } } - SummaryStatisticsType.MOST_BLOCKED_COUNTRIES -> { - uiCtx { - itemBinding.ssIcon.visibility = View.GONE - itemBinding.ssFlag.visibility = View.VISIBLE - itemBinding.ssFlag.text = appConnection.flag - } - } } } private fun setName(appConnection: AppConnection) { when (type) { + SummaryStatisticsType.TOP_ACTIVE_CONNS -> { + io { + val appInfo = FirewallManager.getAppInfoByUid(appConnection.uid) + uiCtx { + val appName = getAppName(appConnection, appInfo) + itemBinding.ssDataUsage.visibility = View.VISIBLE + itemBinding.ssDataUsage.text = appName + } + } + } SummaryStatisticsType.MOST_CONNECTED_APPS -> { io { val appInfo = FirewallManager.getAppInfoByUid(appConnection.uid) @@ -284,15 +329,27 @@ class SummaryStatisticsAdapter( } } } + SummaryStatisticsType.MOST_CONNECTED_ASN -> { + itemBinding.ssDataUsage.visibility = View.VISIBLE + itemBinding.ssDataUsage.text = appConnection.appOrDnsName + } + SummaryStatisticsType.MOST_BLOCKED_ASN -> { + itemBinding.ssDataUsage.visibility = View.VISIBLE + itemBinding.ssDataUsage.text = appConnection.appOrDnsName + } SummaryStatisticsType.MOST_CONTACTED_DOMAINS -> { itemBinding.ssContainer.visibility = View.VISIBLE itemBinding.ssDataUsage.visibility = View.VISIBLE + // now there won't be any trailing '.' in the domain name, from v0.5.5o + // TODO: remove this in later versions itemBinding.ssDataUsage.text = appConnection.appOrDnsName?.dropLastWhile { it == '.' } } SummaryStatisticsType.MOST_BLOCKED_DOMAINS -> { itemBinding.ssContainer.visibility = View.VISIBLE itemBinding.ssDataUsage.visibility = View.VISIBLE + // now there won't be any trailing '.' in the domain name, from v0.5.5o + // TODO: remove this in later versions itemBinding.ssDataUsage.text = appConnection.appOrDnsName?.dropLastWhile { it == '.' } } @@ -306,11 +363,16 @@ class SummaryStatisticsAdapter( } SummaryStatisticsType.MOST_CONTACTED_COUNTRIES -> { itemBinding.ssDataUsage.visibility = View.VISIBLE - itemBinding.ssDataUsage.text = getCountryNameFromFlag(appConnection.flag) - } - SummaryStatisticsType.MOST_BLOCKED_COUNTRIES -> { - itemBinding.ssDataUsage.visibility = View.VISIBLE - itemBinding.ssDataUsage.text = getCountryNameFromFlag(appConnection.flag) + val flag = getCountryNameFromFlag(appConnection.flag) + if (flag.isNotEmpty() && flag != "--") { + itemBinding.ssDataUsage.text = getCountryNameFromFlag(appConnection.flag) + } else { + itemBinding.ssDataUsage.text = context.getString( + R.string.two_argument_space, + context.getString(R.string.network_log_app_name_unknown), + appConnection.flag + ) + } } } } @@ -320,7 +382,7 @@ class SummaryStatisticsAdapter( if (appInfo?.appName.isNullOrEmpty()) { context.getString(R.string.network_log_app_name_unnamed, "($appConnection.uid)") } else { - appInfo?.appName + appInfo?.appName ?: context.getString(R.string.network_log_app_name_unnamed, "(${appConnection.uid})") } } else { appConnection.appOrDnsName @@ -366,21 +428,23 @@ class SummaryStatisticsAdapter( private fun setupClickListeners(appConnection: AppConnection) { itemBinding.ssContainer.setOnClickListener { when (type) { + SummaryStatisticsType.TOP_ACTIVE_CONNS -> { + startAppInfoActivity(appConnection) + } SummaryStatisticsType.MOST_CONNECTED_APPS -> { startAppInfoActivity(appConnection) } SummaryStatisticsType.MOST_BLOCKED_APPS -> { startAppInfoActivity(appConnection) } + SummaryStatisticsType.MOST_CONNECTED_ASN -> { + startDomainConnectionsActivity(appConnection, DomainConnectionsActivity.InputType.ASN) + } + SummaryStatisticsType.MOST_BLOCKED_ASN -> { + startDomainConnectionsActivity(appConnection, DomainConnectionsActivity.InputType.ASN, true) + } SummaryStatisticsType.MOST_CONTACTED_DOMAINS -> { - if (appConfig.getBraveMode().isDnsMode()) { - showDnsLogs(appConnection) - } else { - showNetworkLogs( - appConnection, - SummaryStatisticsType.MOST_CONTACTED_DOMAINS - ) - } + startDomainConnectionsActivity(appConnection, DomainConnectionsActivity.InputType.DOMAIN) } SummaryStatisticsType.MOST_BLOCKED_DOMAINS -> { io { @@ -409,21 +473,34 @@ class SummaryStatisticsAdapter( showNetworkLogs(appConnection, SummaryStatisticsType.MOST_BLOCKED_IPS) } SummaryStatisticsType.MOST_CONTACTED_COUNTRIES -> { - showNetworkLogs( - appConnection, - SummaryStatisticsType.MOST_CONTACTED_COUNTRIES - ) - } - SummaryStatisticsType.MOST_BLOCKED_COUNTRIES -> { - showNetworkLogs(appConnection, SummaryStatisticsType.MOST_BLOCKED_COUNTRIES) + startDomainConnectionsActivity(appConnection, DomainConnectionsActivity.InputType.FLAG) } } } } + private fun startDomainConnectionsActivity(appConnection: AppConnection, input: DomainConnectionsActivity.InputType, isBlocked: Boolean = false) { + val intent = Intent(context, DomainConnectionsActivity::class.java) + intent.putExtra(DomainConnectionsActivity.INTENT_EXTRA_TYPE, input.type) + when (input) { + DomainConnectionsActivity.InputType.DOMAIN -> { + intent.putExtra(DomainConnectionsActivity.INTENT_EXTRA_DOMAIN, appConnection.appOrDnsName) + } + DomainConnectionsActivity.InputType.ASN -> { + intent.putExtra(DomainConnectionsActivity.INTENT_EXTRA_ASN, appConnection.appOrDnsName) + intent.putExtra(DomainConnectionsActivity.INTENT_EXTRA_IS_BLOCKED, isBlocked) + } + DomainConnectionsActivity.InputType.FLAG -> { + intent.putExtra(DomainConnectionsActivity.INTENT_EXTRA_FLAG, appConnection.flag) + } + } + intent.putExtra(DomainConnectionsActivity.INTENT_EXTRA_TIME_CATEGORY, timeCategory.value) + context.startActivity(intent) + } + private fun startAppInfoActivity(appConnection: AppConnection) { val intent = Intent(context, AppInfoActivity::class.java) - intent.putExtra(AppInfoActivity.UID_INTENT_NAME, appConnection.uid) + intent.putExtra(AppInfoActivity.INTENT_UID, appConnection.uid) context.startActivity(intent) } @@ -481,9 +558,6 @@ class SummaryStatisticsAdapter( SummaryStatisticsType.MOST_CONTACTED_COUNTRIES -> { startActivity(NetworkLogsActivity.Tabs.NETWORK_LOGS.screen, appConnection.flag) } - SummaryStatisticsType.MOST_BLOCKED_COUNTRIES -> { - startActivity(NetworkLogsActivity.Tabs.NETWORK_LOGS.screen, appConnection.flag) - } else -> { // should never happen, but just in case we'll show all logs startActivity(NetworkLogsActivity.Tabs.NETWORK_LOGS.screen, "") @@ -505,7 +579,6 @@ class SummaryStatisticsAdapter( private fun startActivity(screenToLoad: Int, searchParam: String?) { val intent = Intent(context, NetworkLogsActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED intent.putExtra(Constants.VIEW_PAGER_SCREEN_TO_LOAD, screenToLoad) intent.putExtra(Constants.SEARCH_QUERY, searchParam ?: "") context.startActivity(intent) @@ -550,8 +623,8 @@ class SummaryStatisticsAdapter( } } ) - } catch (e: Exception) { - Logger.d(LOG_TAG_DNS, "Error loading icon, load flag instead") + } catch (ignored: Exception) { + Logger.d(LOG_TAG_DNS, "err loading icon, load flag instead") displayDuckduckgoFavIcon(duckDuckGoUrl, duckduckgoDomainURL) } } @@ -597,7 +670,7 @@ class SummaryStatisticsAdapter( } } ) - } catch (e: Exception) { + } catch (ignored: Exception) { Logger.d(LOG_TAG_DNS, "err loading icon, load flag instead") showFlag() hideFavIcon() diff --git a/app/src/full/java/com/celzero/bravedns/adapter/WgConfigAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/WgConfigAdapter.kt index 48c019a84..e7c85be1c 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/WgConfigAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/WgConfigAdapter.kt @@ -15,6 +15,7 @@ */ package com.celzero.bravedns.adapter +import Logger.LOG_TAG_PROXY import android.content.Context import android.content.Intent import android.text.format.DateUtils @@ -28,33 +29,44 @@ import androidx.lifecycle.lifecycleScope import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView -import backend.Backend -import backend.Stats +import com.celzero.firestack.backend.RouterStats import com.celzero.bravedns.R +import com.celzero.bravedns.adapter.OneWgConfigAdapter.DnsStatusListener import com.celzero.bravedns.database.WgConfigFiles +import com.celzero.bravedns.database.WgConfigFilesImmutable import com.celzero.bravedns.databinding.ListItemWgGeneralInterfaceBinding +import com.celzero.bravedns.net.doh.Transaction import com.celzero.bravedns.service.ProxyManager +import com.celzero.bravedns.service.ProxyManager.ID_WG_BASE import com.celzero.bravedns.service.VpnController import com.celzero.bravedns.service.WireguardManager +import com.celzero.bravedns.service.WireguardManager.ERR_CODE_OTHER_WG_ACTIVE +import com.celzero.bravedns.service.WireguardManager.ERR_CODE_VPN_NOT_ACTIVE +import com.celzero.bravedns.service.WireguardManager.ERR_CODE_VPN_NOT_FULL +import com.celzero.bravedns.service.WireguardManager.ERR_CODE_WG_INVALID +import com.celzero.bravedns.service.WireguardManager.WG_HANDSHAKE_TIMEOUT +import com.celzero.bravedns.service.WireguardManager.WG_UPTIME_THRESHOLD import com.celzero.bravedns.ui.activity.WgConfigDetailActivity import com.celzero.bravedns.ui.activity.WgConfigEditorActivity.Companion.INTENT_EXTRA_WG_ID import com.celzero.bravedns.util.UIUtils +import com.celzero.bravedns.util.UIUtils.fetchColor import com.celzero.bravedns.util.Utilities -import java.util.concurrent.ConcurrentHashMap +import com.celzero.bravedns.wireguard.WgHopManager +import com.celzero.bravedns.wireguard.WgInterface import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -class WgConfigAdapter(private val context: Context) : +class WgConfigAdapter(private val context: Context, private val listener: DnsStatusListener, private val splitDns: Boolean) : PagingDataAdapter(DIFF_CALLBACK) { - private var configs: ConcurrentHashMap = ConcurrentHashMap() private var lifecycleOwner: LifecycleOwner? = null companion object { private const val ONE_SEC_MS = 1500L + private const val TAG = "WgConfigAdapter" private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { @@ -62,18 +74,14 @@ class WgConfigAdapter(private val context: Context) : oldConnection: WgConfigFiles, newConnection: WgConfigFiles ): Boolean { - return (oldConnection == newConnection) + return oldConnection == newConnection } override fun areContentsTheSame( oldConnection: WgConfigFiles, newConnection: WgConfigFiles ): Boolean { - return (oldConnection.id == newConnection.id && - oldConnection.name == newConnection.name && - oldConnection.isActive == newConnection.isActive && - oldConnection.isCatchAll == newConnection.isCatchAll && - oldConnection.isLockdown == newConnection.isLockdown) + return oldConnection == newConnection } } } @@ -91,35 +99,37 @@ class WgConfigAdapter(private val context: Context) : parent, false ) - lifecycleOwner = parent.findViewTreeLifecycleOwner() + if (lifecycleOwner == null) { + lifecycleOwner = parent.findViewTreeLifecycleOwner() + } return WgInterfaceViewHolder(itemBinding) } override fun onViewDetachedFromWindow(holder: WgInterfaceViewHolder) { super.onViewDetachedFromWindow(holder) - configs.values.forEach { it.cancel() } - configs.clear() + holder.cancelJobIfAny() } inner class WgInterfaceViewHolder(private val b: ListItemWgGeneralInterfaceBinding) : RecyclerView.ViewHolder(b.root) { + private var job: Job? = null fun update(config: WgConfigFiles) { - b.interfaceNameText.text = config.name - b.interfaceSwitch.isChecked = config.isActive + b.interfaceNameText.text = config.name.take(12) + b.interfaceIdText.text = context.getString(R.string.single_argument_parenthesis, config.id.toString()) + b.interfaceSwitch.isChecked = config.isActive && VpnController.hasTunnel() setupClickListeners(config) updateStatusJob(config) + updateHopSrcChip(config.id) + updateAmneziaChip(config) + updateHoppingChip(config.id) } private fun updateStatusJob(config: WgConfigFiles) { - if (config.isActive) { - val job = updateProxyStatusContinuously(config) - if (job != null) { - // cancel the job if it already exists for the same config - cancelJobIfAny(config.id) - configs[config.id] = job - } + if (config.isActive && VpnController.hasTunnel()) { + job = updateProxyStatusContinuously(config) } else { + cancelJobIfAny() disableInactiveConfig(config) } } @@ -129,25 +139,27 @@ class WgConfigAdapter(private val context: Context) : if (config.isLockdown) { b.protocolInfoChipGroup.visibility = View.GONE b.interfaceActiveLayout.visibility = View.GONE - b.interfaceConfigStatus.text = - context.getString(R.string.lbl_disabled).replaceFirstChar(Char::titlecase) - val id = ProxyManager.ID_WG_BASE + config.id + b.interfaceStatus.visibility = View.GONE + val id = ID_WG_BASE + config.id val appsCount = ProxyManager.getAppCountForProxy(id) updateUi(config, appsCount) } else { - b.interfaceStatus.visibility = View.GONE + b.interfaceConfigStatus.visibility = View.GONE b.interfaceAppsCount.visibility = View.GONE b.interfaceActiveLayout.visibility = View.GONE - b.interfaceDetailCard.strokeColor = UIUtils.fetchColor(context, R.attr.background) + b.interfaceDetailCard.strokeColor = fetchColor(context, R.attr.background) b.interfaceDetailCard.strokeWidth = 0 b.interfaceSwitch.isChecked = false b.protocolInfoChipGroup.visibility = View.GONE - b.interfaceConfigStatus.visibility = View.VISIBLE - b.interfaceConfigStatus.text = + b.interfaceStatus.visibility = View.VISIBLE + b.interfaceStatus.text = context.getString(R.string.lbl_disabled).replaceFirstChar(Char::titlecase) + updateProtocolChip(Pair(false, false)) + updateSplitTunnelChip(false) + updateHopSrcChip(config.id) + updateAmneziaChip(config) + updateHoppingChip(config.id) } - // cancel the job if it already exists for the config, as the config is disabled - cancelJobIfAny(config.id) } private fun updateProxyStatusContinuously(config: WgConfigFiles): Job? { @@ -163,7 +175,6 @@ class WgConfigAdapter(private val context: Context) : if (pair == null) return if (!pair.first && !pair.second) { - b.protocolInfoChipGroup.visibility = View.GONE b.protocolInfoChipIpv4.visibility = View.GONE b.protocolInfoChipIpv6.visibility = View.GONE return @@ -193,24 +204,49 @@ class WgConfigAdapter(private val context: Context) : } } - private fun cancelJobIfAny(id: Int) { - val job = configs[id] - job?.cancel() - configs.remove(id) + private fun updateHopSrcChip(id: Int) { + val sid = ID_WG_BASE + id + val hop = WgHopManager.getMapBySrc(sid) + if (hop.isNotEmpty()) { + b.protocolInfoChipGroup.visibility = View.VISIBLE + b.chipHopSrc.visibility = View.VISIBLE + b.chipHopSrc.text = context.getString( + R.string.two_argument_colon, context.getString(R.string.hop_lbl), + hop.joinToString { it.hop }) + } else { + b.chipHopSrc.visibility = View.GONE + } } - private fun cancelAllJobs() { - configs.values.forEach { it.cancel() } - configs.clear() + private fun updateHoppingChip(id: Int) { + val sid = ID_WG_BASE + id + val hop = WgHopManager.isAlreadyHop(sid) + if (hop) { + b.protocolInfoChipGroup.visibility = View.VISIBLE + b.chipHopping.visibility = View.VISIBLE + } else { + b.chipHopping.visibility = View.GONE + } + } + + fun cancelJobIfAny() { + if (job?.isActive == true) { + job?.cancel() + } } private suspend fun updateStatus(config: WgConfigFiles) { - val id = ProxyManager.ID_WG_BASE + config.id + val id = ID_WG_BASE + config.id val appsCount = ProxyManager.getAppCountForProxy(id) val statusId = VpnController.getProxyStatusById(id) val pair = VpnController.getSupportedIpVersion(id) val c = WireguardManager.getConfigById(config.id) val stats = VpnController.getProxyStats(id) + val dnsStatusId = if (splitDns) { + VpnController.getDnsStatus(id) + } else { + null + } val isSplitTunnel = if (c?.getPeers()?.isNotEmpty() == true) { VpnController.isSplitTunnelProxy(id, pair) @@ -221,37 +257,57 @@ class WgConfigAdapter(private val context: Context) : // if the view is not active then cancel the job if ( lifecycleOwner != null && - lifecycleOwner - ?.lifecycle - ?.currentState - ?.isAtLeast(androidx.lifecycle.Lifecycle.State.STARTED) == false + lifecycleOwner + ?.lifecycle + ?.currentState + ?.isAtLeast(androidx.lifecycle.Lifecycle.State.STARTED) == false ) { - cancelAllJobs() + cancelJobIfAny() return } uiCtx { - updateStatusUi(config, statusId, stats) + updateStatusUi(config, statusId, dnsStatusId, stats) updateUi(config, appsCount) updateProtocolChip(pair) updateSplitTunnelChip(isSplitTunnel) } } + private fun updateAmneziaChip(config: WgConfigFiles) { + val c = WireguardManager.getConfigById(config.id) ?: return + + c.getInterface()?.let { + if (isAmneziaConfig(it)) { + b.protocolInfoChipGroup.visibility = View.VISIBLE + b.chipAmnezia.visibility = View.VISIBLE + } else { + b.chipAmnezia.visibility = View.GONE + } + } + } + + private fun isAmneziaConfig(c: WgInterface): Boolean { + // TODO: should we add more checks here? + // consider the config values jc, jmin, jmax, h1, h2, h3, h4, s1, s2 + return c.getJc().isPresent || c.getJmin().isPresent || c.getJmax().isPresent || + c.getH1().isPresent || c.getH2().isPresent || c.getH3().isPresent || + c.getH4().isPresent || c.getS1().isPresent || c.getS2().isPresent + } + private fun updateUi(config: WgConfigFiles, appsCount: Int) { b.interfaceAppsCount.visibility = View.VISIBLE if (config.isCatchAll) { b.interfaceConfigStatus.visibility = View.VISIBLE b.interfaceAppsCount.text = context.getString(R.string.routing_remaining_apps) b.interfaceAppsCount.setTextColor( - UIUtils.fetchColor(context, R.attr.primaryLightColorText) + fetchColor(context, R.attr.primaryLightColorText) ) b.interfaceConfigStatus.text = context.getString(R.string.catch_all_wg_dialog_title) return // no need to update the apps count } else if (config.isLockdown) { if (!config.isActive) { b.interfaceDetailCard.strokeWidth = 2 - b.interfaceDetailCard.strokeColor = - UIUtils.fetchColor(context, R.attr.accentBad) + b.interfaceDetailCard.strokeColor = fetchColor(context, R.attr.accentBad) } b.interfaceConfigStatus.visibility = View.VISIBLE b.interfaceConfigStatus.text = @@ -269,25 +325,21 @@ class WgConfigAdapter(private val context: Context) : b.interfaceAppsCount.text = context.getString(R.string.firewall_card_status_active, appsCount.toString()) if (appsCount == 0) { - b.interfaceAppsCount.setTextColor(UIUtils.fetchColor(context, R.attr.accentBad)) + b.interfaceAppsCount.setTextColor(fetchColor(context, R.attr.accentBad)) } else { - b.interfaceAppsCount.setTextColor( - UIUtils.fetchColor(context, R.attr.primaryLightColorText) - ) + b.interfaceAppsCount.setTextColor(fetchColor(context, R.attr.primaryLightColorText)) } } - private fun updateStatusUi(config: WgConfigFiles, statusId: Long?, stats: Stats?) { + private fun updateStatusUi(config: WgConfigFiles, statusPair: Pair, dnsStatusId: Long?, stats: RouterStats?) { if (config.isActive) { b.interfaceSwitch.isChecked = true b.interfaceDetailCard.strokeWidth = 2 b.interfaceStatus.visibility = View.VISIBLE b.interfaceConfigStatus.visibility = View.VISIBLE - var status: String b.interfaceActiveLayout.visibility = View.VISIBLE val time = getUpTime(stats) val rxtx = getRxTx(stats) - val handShakeTime = getHandshakeTime(stats) if (time.isNotEmpty()) { val t = context.getString(R.string.logs_card_duration, time) b.interfaceActiveUptime.text = @@ -300,76 +352,120 @@ class WgConfigAdapter(private val context: Context) : b.interfaceActiveUptime.text = context.getString(R.string.lbl_active) } b.interfaceActiveRxTx.text = rxtx - if (statusId != null) { - var resId = UIUtils.getProxyStatusStringRes(statusId) - // change the color based on the status - if (statusId == Backend.TOK) { - // if the lastOK is 0, then the handshake is not yet completed - // so show the status as waiting - if (stats?.lastOK == 0L) { - b.interfaceDetailCard.strokeColor = - UIUtils.fetchColor(context, R.attr.chipTextNeutral) - resId = R.string.status_waiting - } else { - b.interfaceDetailCard.strokeColor = - UIUtils.fetchColor(context, R.attr.accentGood) - } - } else if ( - statusId == Backend.TUP || - statusId == Backend.TZZ || - statusId == Backend.TNT - ) { - b.interfaceDetailCard.strokeColor = - UIUtils.fetchColor(context, R.attr.chipTextNeutral) - } else { + + if (dnsStatusId != null) { + // check for dns failure cases and update the UI + if (isDnsError(dnsStatusId)) { b.interfaceDetailCard.strokeColor = - UIUtils.fetchColor(context, R.attr.accentBad) - } - status = - if (stats?.lastOK == 0L) { - context.getString(resId).replaceFirstChar(Char::titlecase) + fetchColor(context, R.attr.chipTextNegative) + val humanReadableLastOk = getHumanReadableLastOk(stats).toString() + // show last ok time if available + if (humanReadableLastOk.isEmpty()) { + b.interfaceStatus.text = context.getString( + R.string.status_failing + ).replaceFirstChar(Char::titlecase) } else { - context.getString( + b.interfaceStatus.text = context.getString( R.string.about_version_install_source, - context.getString(resId).replaceFirstChar(Char::titlecase), - handShakeTime + context.getString(R.string.status_failing) + .replaceFirstChar(Char::titlecase), humanReadableLastOk ) } - if ((statusId == Backend.TZZ || statusId == Backend.TNT) && stats != null) { - // for idle state, if lastOk is less than 30 sec, then show as connected - if ( - stats.lastOK != 0L && - System.currentTimeMillis() - stats.lastOK < - 30 * DateUtils.SECOND_IN_MILLIS - ) { - status = - context - .getString(R.string.dns_connected) - .replaceFirstChar(Char::titlecase) - } + } else { + // if dns status is not failing, then update the proxy status + updateProxyStatusUi(statusPair, stats) } } else { - b.interfaceDetailCard.strokeColor = - UIUtils.fetchColor(context, R.attr.accentBad) - status = - context.getString(R.string.status_waiting).replaceFirstChar(Char::titlecase) - b.interfaceActiveLayout.visibility = View.GONE + // in one wg mode, if dns status should be available, this is a fallback case + updateProxyStatusUi(statusPair, stats) } - b.interfaceStatus.text = status } else { b.interfaceActiveLayout.visibility = View.GONE - b.interfaceDetailCard.strokeColor = UIUtils.fetchColor(context, R.attr.background) + b.interfaceDetailCard.strokeColor = fetchColor(context, R.attr.background) b.interfaceDetailCard.strokeWidth = 0 b.interfaceSwitch.isChecked = false - b.interfaceStatus.visibility = View.GONE + b.interfaceConfigStatus.visibility = View.GONE b.interfaceAppsCount.visibility = View.GONE - b.interfaceConfigStatus.visibility = View.VISIBLE - b.interfaceConfigStatus.text = + b.interfaceStatus.visibility = View.VISIBLE + b.interfaceStatus.text = context.getString(R.string.lbl_disabled).replaceFirstChar(Char::titlecase) } } - private fun getRxTx(stats: Stats?): String { + private fun getStrokeColorForStatus(status: UIUtils.ProxyStatus?, stats: RouterStats?): Int { + return when (status) { + UIUtils.ProxyStatus.TOK -> if (stats?.lastOK == 0L) return R.attr.chipTextNeutral else R.attr.accentGood + UIUtils.ProxyStatus.TUP, UIUtils.ProxyStatus.TZZ -> R.attr.chipTextNeutral + else -> R.attr.chipTextNegative // TNT, TKO, TEND + } + } + + private fun getStatusText( + status: UIUtils.ProxyStatus?, + humanReadableLastOk: String? = null, + stats: RouterStats?, + errMsg: String? = null + ): String { + if (status == null) { + val txt = if (errMsg != null) { + context.getString(R.string.status_waiting) + " ($errMsg)" + } else { + context.getString(R.string.status_waiting) + } + return txt.replaceFirstChar(Char::titlecase) + } + + val now = System.currentTimeMillis() + val lastOk = stats?.lastOK ?: 0L + val since = stats?.since ?: 0L + if (now - since > WG_UPTIME_THRESHOLD && lastOk == 0L) { + return context.getString(R.string.status_failing).replaceFirstChar(Char::titlecase) + } + + val baseText = context.getString(UIUtils.getProxyStatusStringRes(status.id)) + .replaceFirstChar(Char::titlecase) + + return if (stats?.lastOK != 0L && humanReadableLastOk != null) { + context.getString(R.string.about_version_install_source, baseText, humanReadableLastOk) + } else { + baseText + } + } + + private fun getIdleStatusText(status: UIUtils.ProxyStatus?, stats: RouterStats?): String { + if (status != UIUtils.ProxyStatus.TZZ && status != UIUtils.ProxyStatus.TNT) return "" + if (stats == null || stats.lastOK == 0L) return "" + if (System.currentTimeMillis() - stats.since >= WG_HANDSHAKE_TIMEOUT) return "" + + return context.getString(R.string.dns_connected).replaceFirstChar(Char::titlecase) + } + + private fun updateProxyStatusUi(statusPair: Pair, stats: RouterStats?) { + val status = UIUtils.ProxyStatus.entries.find { it.id == statusPair.first } // Convert to enum + + val humanReadableLastOk = getHumanReadableLastOk(stats).toString() + + val strokeColor = getStrokeColorForStatus(status, stats) + b.interfaceDetailCard.strokeColor = fetchColor(context, strokeColor) + val statusText = getIdleStatusText(status, stats).ifEmpty { + getStatusText( + status, + humanReadableLastOk, + stats, + statusPair.second + ) + } + b.interfaceStatus.text = statusText + } + + private fun isDnsError(statusId: Long?): Boolean { + if (statusId == null) return true + + val s = Transaction.Status.fromId(statusId) + return s == Transaction.Status.BAD_QUERY || s == Transaction.Status.BAD_RESPONSE || s == Transaction.Status.NO_RESPONSE || s == Transaction.Status.SEND_FAIL || s == Transaction.Status.CLIENT_ERROR || s == Transaction.Status.INTERNAL_ERROR || s == Transaction.Status.TRANSPORT_ERROR + } + + private fun getRxTx(stats: RouterStats?): String { if (stats == null) return "" val rx = context.getString( @@ -381,13 +477,16 @@ class WgConfigAdapter(private val context: Context) : R.string.symbol_upload, Utilities.humanReadableByteCount(stats.tx, true) ) - return context.getString(R.string.two_argument_space, rx, tx) + return context.getString(R.string.two_argument_space, tx, rx) } - private fun getUpTime(stats: Stats?): CharSequence { + private fun getUpTime(stats: RouterStats?): CharSequence { if (stats == null) { return "" } + if (stats.since <= 0L) { + return "" + } val now = System.currentTimeMillis() // returns a string describing 'time' as a time relative to 'now' return DateUtils.getRelativeTimeSpanString( @@ -398,11 +497,11 @@ class WgConfigAdapter(private val context: Context) : ) } - private fun getHandshakeTime(stats: Stats?): CharSequence { + private fun getHumanReadableLastOk(stats: RouterStats?): CharSequence { if (stats == null) { return "" } - if (stats.lastOK == 0L) { + if (stats.lastOK <= 0L) { return "" } val now = System.currentTimeMillis() @@ -421,33 +520,124 @@ class WgConfigAdapter(private val context: Context) : b.interfaceSwitch.setOnCheckedChangeListener(null) b.interfaceSwitch.setOnClickListener { val cfg = config.toImmutable() - if (b.interfaceSwitch.isChecked) { - if (WireguardManager.canEnableConfig(cfg)) { - WireguardManager.enableConfig(cfg) - } else { - Utilities.showToastUiCentered( - context, - context.getString(R.string.wireguard_enabled_failure), - Toast.LENGTH_LONG - ) - b.interfaceSwitch.isChecked = false - } - } else { - if (WireguardManager.canDisableConfig(cfg)) { - WireguardManager.disableConfig(cfg) + io { + if (b.interfaceSwitch.isChecked) { + enableWgIfPossible(cfg) } else { - Utilities.showToastUiCentered( - context, - context.getString(R.string.wireguard_disable_failure), - Toast.LENGTH_LONG - ) - b.interfaceSwitch.isChecked = true + disableWgIfPossible(cfg) } } } } + private suspend fun disableWgIfPossible(cfg: WgConfigFilesImmutable) { + if (!VpnController.hasTunnel()) { + Logger.i(LOG_TAG_PROXY, "$TAG VPN not active, cannot enable WireGuard") + uiCtx { + Utilities.showToastUiCentered( + context, + ERR_CODE_VPN_NOT_ACTIVE + + context.getString(R.string.settings_socks5_vpn_disabled_error), + Toast.LENGTH_LONG + ) + // reset the check box + b.interfaceSwitch.isChecked = true + } + return + } + + if (WireguardManager.canDisableConfig(cfg)) { + WireguardManager.disableConfig(cfg) + } else { + uiCtx { + Utilities.showToastUiCentered( + context, + context.getString(R.string.wireguard_disable_failure), + Toast.LENGTH_LONG + ) + b.interfaceSwitch.isChecked = true + } + } + + uiCtx { listener.onDnsStatusChanged() } + } + + private suspend fun enableWgIfPossible(cfg: WgConfigFilesImmutable) { + + if (!VpnController.hasTunnel()) { + Logger.i(LOG_TAG_PROXY, "$TAG VPN not active, cannot enable WireGuard") + uiCtx { + Utilities.showToastUiCentered( + context, + ERR_CODE_VPN_NOT_ACTIVE + + context.getString(R.string.settings_socks5_vpn_disabled_error), + Toast.LENGTH_LONG + ) + // reset the check box + b.interfaceSwitch.isChecked = false + } + return + } + + if (!WireguardManager.canEnableProxy()) { + Logger.i(LOG_TAG_PROXY, "$TAG not in DNS+Firewall mode, cannot enable WireGuard") + uiCtx { + // reset the check box + b.interfaceSwitch.isChecked = false + Utilities.showToastUiCentered( + context, + ERR_CODE_VPN_NOT_FULL + + context.getString(R.string.wireguard_enabled_failure), + Toast.LENGTH_LONG + ) + } + return + } + + if (WireguardManager.oneWireGuardEnabled()) { + // this should not happen, ui is disabled if one wireGuard is enabled + Logger.w(LOG_TAG_PROXY, "$TAG one wireGuard is already enabled") + uiCtx { + // reset the check box + b.interfaceSwitch.isChecked = false + Utilities.showToastUiCentered( + context, + ERR_CODE_OTHER_WG_ACTIVE + + context.getString(R.string.wireguard_enabled_failure), + Toast.LENGTH_LONG + ) + } + return + } + + if (!WireguardManager.isValidConfig(cfg.id)) { + Logger.i(LOG_TAG_PROXY, "$TAG invalid WireGuard config") + uiCtx { + // reset the check box + b.interfaceSwitch.isChecked = false + Utilities.showToastUiCentered( + context, + ERR_CODE_WG_INVALID + context.getString(R.string.wireguard_enabled_failure), + Toast.LENGTH_LONG + ) + } + return + } + + WireguardManager.enableConfig(cfg) + uiCtx { listener.onDnsStatusChanged() } + } + private fun launchConfigDetail(id: Int) { + if (!VpnController.hasTunnel()) { + Utilities.showToastUiCentered( + context, + context.getString(R.string.ssv_toast_start_rethink), + Toast.LENGTH_SHORT + ) + return + } + val intent = Intent(context, WgConfigDetailActivity::class.java) intent.putExtra(INTENT_EXTRA_WG_ID, id) intent.putExtra( diff --git a/app/src/full/java/com/celzero/bravedns/adapter/WgHopAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/WgHopAdapter.kt new file mode 100644 index 000000000..571c7be66 --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/adapter/WgHopAdapter.kt @@ -0,0 +1,353 @@ +/* + * Copyright 2025 RethinkDNS and its authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.celzero.bravedns.adapter + +import Logger +import Logger.LOG_TAG_UI +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.RecyclerView +import com.celzero.bravedns.R +import com.celzero.bravedns.databinding.ListItemWgHopBinding +import com.celzero.bravedns.service.ProxyManager.ID_WG_BASE +import com.celzero.bravedns.service.VpnController +import com.celzero.bravedns.service.WireguardManager +import com.celzero.bravedns.util.UIUtils +import com.celzero.bravedns.util.UIUtils.fetchColor +import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.wireguard.Config +import com.celzero.bravedns.wireguard.WgHopManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class WgHopAdapter( + private val context: Context, + private val srcId: Int, + private val hopables: List, + private var selectedId: Int +) : RecyclerView.Adapter() { + + companion object { + private const val TAG = "HopAdapter" + } + + private var isAttached = false + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HopViewHolder { + val itemBinding = + ListItemWgHopBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return HopViewHolder(itemBinding) + } + + override fun getItemCount(): Int { + return hopables.size + } + + override fun onBindViewHolder(holder: HopViewHolder, position: Int) { + holder.update(hopables[position]) + } + + override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + super.onAttachedToRecyclerView(recyclerView) + isAttached = true + } + + override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { + super.onDetachedFromRecyclerView(recyclerView) + isAttached = false + } + + inner class HopViewHolder(private val b: ListItemWgHopBinding) : + RecyclerView.ViewHolder(b.root) { + + private var processingDialog: androidx.appcompat.app.AlertDialog? = null + + fun update(config: Config) { + val mapping = WireguardManager.getConfigFilesById(config.getId()) ?: return + b.wgHopListNameTv.text = config.getName() + " (" + config.getId() + ")" + b.wgHopListCheckbox.isChecked = config.getId() == selectedId + setCardStroke(config.getId() == selectedId, mapping.isActive) + showChips(config) + updateStatusUi(config) + setupClickListeners(config, mapping.isActive) + } + + private fun updateStatusUi(config: Config) { + io { + val map = WireguardManager.getConfigFilesById(config.getId()) + if (map == null) { + uiCtx { + b.wgHopListDescTv.text = context.getString(R.string.config_invalid_desc) + } + return@io + } + if (selectedId == config.getId()) { + val srcConfig = WireguardManager.getConfigById(srcId) + if (srcConfig == null) { + Logger.i(LOG_TAG_UI, "$TAG; source config($srcId) not found to hop") + uiCtx { + b.wgHopListDescTv.text = context.getString(R.string.lbl_inactive) + } + return@io + } + val src = ID_WG_BASE + srcConfig.getId() + val hop = ID_WG_BASE + config.getId() + val statusPair = VpnController.hopStatus(src, hop) + uiCtx { + val id = statusPair.first + if (statusPair.first != null) { + val txt = UIUtils.getProxyStatusStringRes(id) + b.wgHopListDescTv.text = context.getString(txt) + } else { + b.wgHopListDescTv.text = statusPair.second + } + } + return@io + } + if (map.isActive) { + uiCtx { + b.wgHopListDescTv.text = context.getString(R.string.lbl_active) + } + return@io + } else { + uiCtx { + b.wgHopListDescTv.text = context.getString(R.string.lbl_inactive) + } + } + } + } + + private fun showChips(config: Config) { + io { + val id = ID_WG_BASE + config.getId() + val pair = VpnController.getSupportedIpVersion(id) + val isSplitTunnel = if (config.getPeers()?.isNotEmpty() == true) { + VpnController.isSplitTunnelProxy(id, pair) + } else { + false + } + uiCtx { + updateAmzChip(config) + updateProtocolChip(pair) + updateSplitTunnelChip(isSplitTunnel) + updateHopSrcChip(config) + updateHoppingChip(config) + } + } + } + + private fun updateAmzChip(config: Config) { + config.getInterface()?.let { + if (it.isAmnezia()) { + b.chipGroup.visibility = View.VISIBLE + b.chipAmnezia.visibility = View.VISIBLE + } else { + b.chipAmnezia.visibility = View.GONE + } + } + } + + private fun updateProtocolChip(pair: Pair?) { + if (pair == null) return + + if (!pair.first && !pair.second) { + b.chipIpv4.visibility = View.GONE + b.chipIpv6.visibility = View.GONE + return + } + b.chipGroup.visibility = View.VISIBLE + b.chipIpv4.visibility = View.GONE + b.chipIpv6.visibility = View.GONE + if (pair.first) { + b.chipIpv4.visibility = View.VISIBLE + b.chipIpv4.text = context.getString(R.string.settings_ip_text_ipv4) + } else { + b.chipIpv4.visibility = View.GONE + } + if (pair.second) { + b.chipIpv6.visibility = View.VISIBLE + b.chipIpv6.text = context.getString(R.string.settings_ip_text_ipv6) + } else { + b.chipIpv6.visibility = View.GONE + } + } + + private fun updateSplitTunnelChip(isSplitTunnel: Boolean) { + if (isSplitTunnel) { + b.chipGroup.visibility = View.VISIBLE + b.chipSplitTunnel.visibility = View.VISIBLE + } else { + b.chipSplitTunnel.visibility = View.GONE + } + } + + private fun updateHopSrcChip(config: Config) { + val id = ID_WG_BASE + config.getId() + val hop = WgHopManager.getMapBySrc(id) + if (hop.isNotEmpty()) { + b.chipGroup.visibility = View.VISIBLE + b.chipHopSrc.visibility = View.VISIBLE + } else { + b.chipHopSrc.visibility = View.GONE + } + } + + private fun updateHoppingChip(config: Config) { + val id = ID_WG_BASE + config.getId() + val hop = WgHopManager.isAlreadyHop(id) + if (hop) { + b.chipGroup.visibility = View.VISIBLE + b.chipHopping.visibility = View.VISIBLE + } else { + b.chipHopping.visibility = View.GONE + } + } + + private fun setupClickListeners(config: Config, isActive: Boolean) { + b.wgHopListCard.setOnClickListener { + io { handleHop(config, !b.wgHopListCheckbox.isChecked, isActive) } + } + + b.wgHopListCheckbox.setOnClickListener { + io { handleHop(config, b.wgHopListCheckbox.isChecked, isActive) } + } + } + + private suspend fun handleHop(config: Config, isChecked: Boolean, isActive: Boolean) { + val srcConfig = WireguardManager.getConfigById(srcId) + + if (srcConfig == null) { + Logger.i(LOG_TAG_UI, "$TAG; source config($srcId) not found to hop") + uiCtx { + if (!isAttached) return@uiCtx + Utilities.showToastUiCentered(context, context.getString(R.string.config_invalid_desc), Toast.LENGTH_LONG) + } + return + } + uiCtx { + showProcessingDialog(context) + } + Logger.d(LOG_TAG_UI, "$TAG; init, hop: ${srcConfig.getId()} -> ${config.getId()}, isChecked? $isChecked") + val src = ID_WG_BASE + srcConfig.getId() + val hop = ID_WG_BASE + config.getId() + val currMap = WgHopManager.getMapBySrc(src) + if (currMap.isNotEmpty()) { + var res = false + currMap.forEach { + if (it.hop != hop && it.hop.isNotEmpty()) { + val id = it.hop.substring(ID_WG_BASE.length).toIntOrNull() ?: return@forEach + res = WgHopManager.removeHop(srcConfig.getId(), id).first + } + } + if (res) { + selectedId = -1 + uiCtx { + if (!isAttached) return@uiCtx + notifyDataSetChanged() + } + } + } + delay(2000) + if (isChecked) { + val hopTestRes = VpnController.testHop(src, hop) + if (!hopTestRes.first) { + uiCtx { + if (!isAttached) return@uiCtx + + dismissProcessingDialog() + b.wgHopListCheckbox.isChecked = false + Utilities.showToastUiCentered( + context, + hopTestRes.second ?: context.getString(R.string.unknown_error), + Toast.LENGTH_LONG + ) + } + return + } + } + + val res = if (!isChecked) { + selectedId = -1 + WgHopManager.removeHop(srcConfig.getId(), config.getId()) + } else { + selectedId = config.getId() + WgHopManager.hop(srcConfig.getId(), config.getId()) + } + uiCtx { + if (!isAttached) return@uiCtx + + dismissProcessingDialog() + Utilities.showToastUiCentered(context, res.second, Toast.LENGTH_LONG) + if (!res.first) { + b.wgHopListCheckbox.isChecked = false + setCardStroke(false, false) + } else { + b.wgHopListCheckbox.isChecked = true + setCardStroke(true, isActive) + } + notifyDataSetChanged() + } + } + + fun showProcessingDialog(context: Context) { + if (!isAttached) return + + val dialogBuilder = MaterialAlertDialogBuilder(context) + dialogBuilder.setTitle(context.getString(R.string.processing_dialog_title)) + dialogBuilder.setMessage(context.getString(R.string.processing_dialog_desc)) + dialogBuilder.setCancelable(true) + processingDialog = dialogBuilder.create() + processingDialog?.show() + } + + fun dismissProcessingDialog() { + if (!isAttached) return + + processingDialog?.dismiss() + processingDialog = null + } + + private fun setCardStroke(isSelected: Boolean, isActive: Boolean) { + val strokeColor = if (isSelected && isActive) { + b.wgHopListCard.strokeWidth = 2 + fetchColor(context, R.attr.chipTextPositive) + } else if (isSelected) { // selected but not active + b.wgHopListCard.strokeWidth = 2 + fetchColor(context, R.attr.chipTextNegative) + } else { + b.wgHopListCard.strokeWidth = 0 + fetchColor(context, R.attr.chipTextNegative) + } + b.wgHopListCard.strokeColor = strokeColor + } + } + + private suspend fun uiCtx(f: suspend () -> Unit) { + withContext(Dispatchers.Main) { f() } + } + + private fun io(f: suspend () -> Unit) { + (context as LifecycleOwner).lifecycleScope.launch { withContext(Dispatchers.IO) { f() } } + } +} diff --git a/app/src/full/java/com/celzero/bravedns/adapter/WgIncludeAppsAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/WgIncludeAppsAdapter.kt index ef6e671e4..26f129bb3 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/WgIncludeAppsAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/WgIncludeAppsAdapter.kt @@ -19,6 +19,7 @@ import Logger import Logger.LOG_TAG_PROXY import android.content.Context import android.content.DialogInterface +import android.content.pm.PackageManager import android.graphics.drawable.Drawable import android.view.LayoutInflater import android.view.View @@ -54,6 +55,7 @@ class WgIncludeAppsAdapter( PagingDataAdapter( DIFF_CALLBACK ) { + private val packageManager: PackageManager = context.packageManager companion object { @@ -74,8 +76,7 @@ class WgIncludeAppsAdapter( oldConnection: ProxyApplicationMapping, newConnection: ProxyApplicationMapping ): Boolean { - return (oldConnection.proxyId == newConnection.proxyId && - oldConnection.uid == newConnection.uid) + return oldConnection == newConnection } } } @@ -104,6 +105,8 @@ class WgIncludeAppsAdapter( b.wgIncludeCard.isFocusable = false b.wgIncludeAppListCheckbox.isClickable = false b.wgIncludeAppListCheckbox.isFocusable = false + b.wgIncludeAppAppDescTv.visibility = View.VISIBLE + b.wgIncludeAppAppDescTv.text = context.getString(R.string.excluded_from_proxy) } else { b.wgIncludeAppListContainer.isEnabled = true b.wgIncludeCard.isClickable = true @@ -124,14 +127,12 @@ class WgIncludeAppsAdapter( b.wgIncludeAppAppDescTv.text = context.getString(R.string.wireguard_apps_proxy_map_desc, mapping.proxyName) } else { - b.wgIncludeAppAppDescTv.text = "" + b.wgIncludeAppAppDescTv.text = context.getString(R.string.excluded_from_proxy) } b.wgIncludeAppAppDescTv.visibility = View.VISIBLE b.wgIncludeAppListCheckbox.isChecked = false setCardBackground(false) } else { - b.wgIncludeAppAppDescTv.text = "" - b.wgIncludeAppAppDescTv.visibility = View.GONE b.wgIncludeAppListCheckbox.isChecked = mapping.proxyId == proxyId && !isProxyExcluded setCardBackground(mapping.proxyId == proxyId && !isProxyExcluded) @@ -139,6 +140,14 @@ class WgIncludeAppsAdapter( val isIncluded = mapping.proxyId == proxyId && mapping.proxyId != "" ui { displayIcon(getIcon(context, mapping.packageName, mapping.appName)) } + // set the alpha based on internet permission + if (mapping.hasInternetPermission(packageManager)) { + b.wgIncludeAppListApkLabelTv.alpha = 1f + b.wgIncludeAppListApkIconIv.alpha = 1f + } else { + b.wgIncludeAppListApkLabelTv.alpha = 0.4f + b.wgIncludeAppListApkIconIv.alpha = 0.4f + } setupClickListeners(mapping, isIncluded) } diff --git a/app/src/full/java/com/celzero/bravedns/adapter/WgNwStatsAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/WgNwStatsAdapter.kt new file mode 100644 index 000000000..99e860537 --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/adapter/WgNwStatsAdapter.kt @@ -0,0 +1,204 @@ +/* + * Copyright 2024 RethinkDNS and its authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.celzero.bravedns.adapter + +import Logger.LOG_TAG_UI +import android.content.Context +import android.graphics.drawable.Drawable +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.celzero.bravedns.R +import com.celzero.bravedns.data.AppConnection +import com.celzero.bravedns.database.AppInfo +import com.celzero.bravedns.databinding.ListItemStatisticsSummaryBinding +import com.celzero.bravedns.service.FirewallManager +import com.celzero.bravedns.util.UIUtils.fetchToggleBtnColors +import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.isAtleastN +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.math.log2 + +class WgNwStatsAdapter(private val context: Context) : + PagingDataAdapter(DIFF_CALLBACK) { + + private var maxValue: Int = 0 + companion object { + private val TAG = WgNwStatsAdapter::class.simpleName + + private val DIFF_CALLBACK = + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(old: AppConnection, new: AppConnection): Boolean { + return (old == new) + } + + override fun areContentsTheSame(old: AppConnection, new: AppConnection): Boolean { + return (old == new) + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WgNwStatsAdapterViewHolder { + val inflater = LayoutInflater.from(parent.context) + val binding = ListItemStatisticsSummaryBinding.inflate(inflater, parent, false) + return WgNwStatsAdapterViewHolder(binding) + } + + override fun onBindViewHolder(holder: WgNwStatsAdapterViewHolder, position: Int) { + val conn = getItem(position) ?: return + holder.bind(conn) + } + + private fun calculatePercentage(c: Double): Int { + val value = (log2(c) * 100).toInt() + // maxValue will be based on the count returned by the database query (order by count desc) + if (value > maxValue) { + maxValue = value + } + return if (maxValue == 0) { + 0 + } else { + (value * 100 / maxValue) + } + } + + inner class WgNwStatsAdapterViewHolder(private val b: ListItemStatisticsSummaryBinding) : + RecyclerView.ViewHolder(b.root) { + + fun bind(conn: AppConnection) { + Logger.d(LOG_TAG_UI, "$TAG: Binding data for ${conn.uid}, ${conn.appOrDnsName}") + setName(conn) + setIcon(conn) + showDataUsage(conn) + setProgress(conn) + setConnectionCount(conn) + // remove right arrow indicator as there won't be any click action + b.ssIndicator.visibility = View.INVISIBLE + } + + private fun setConnectionCount(conn: AppConnection) { + b.ssCount.text = conn.count.toString() + } + + private fun showDataUsage(conn: AppConnection) { + if (conn.downloadBytes == null || conn.uploadBytes == null) { + b.ssName.visibility = View.GONE + b.ssCount.text = conn.count.toString() + return + } + + b.ssName.visibility = View.VISIBLE + val download = + context.getString( + R.string.symbol_download, + Utilities.humanReadableByteCount(conn.downloadBytes, true) + ) + val upload = + context.getString( + R.string.symbol_upload, + Utilities.humanReadableByteCount(conn.uploadBytes, true) + ) + val total = context.getString(R.string.two_argument, upload, download) + b.ssDataUsage.text = total + b.ssCount.text = conn.count.toString() + } + + private fun setIcon(conn: AppConnection) { + io { + val appInfo = FirewallManager.getAppInfoByUid(conn.uid) + uiCtx { + b.ssIcon.visibility = View.VISIBLE + b.ssFlag.visibility = View.GONE + loadAppIcon( + Utilities.getIcon( + context, + appInfo?.packageName ?: "", + appInfo?.appName ?: "" + ) + ) + } + } + } + + private fun setName(conn: AppConnection) { + io { + val appInfo = FirewallManager.getAppInfoByUid(conn.uid) + uiCtx { + val appName = getAppName(conn, appInfo) + b.ssName.visibility = View.VISIBLE + b.ssName.text = appName + } + } + } + + private fun getAppName(conn: AppConnection, appInfo: AppInfo?): String? { + return if (conn.appOrDnsName.isNullOrEmpty()) { + if (appInfo?.appName.isNullOrEmpty()) { + context.getString(R.string.network_log_app_name_unnamed, "($conn.uid)") + } else { + appInfo?.appName + } + } else { + conn.appOrDnsName + } + } + + private fun setProgress(conn: AppConnection) { + val d = conn.downloadBytes ?: 0L + val u = conn.uploadBytes ?: 0L + + val c = (d + u).toDouble() + val percentage = calculatePercentage(c) + b.ssProgress.setIndicatorColor( + fetchToggleBtnColors(context, R.color.accentGood) + ) + if (isAtleastN()) { + b.ssProgress.setProgress(percentage, true) + } else { + b.ssProgress.progress = percentage + } + } + + private fun loadAppIcon(drawable: Drawable?) { + ui { + Glide.with(context) + .load(drawable) + .error(Utilities.getDefaultIcon(context)) + .into(b.ssIcon) + } + } + } + + private fun io(f: suspend () -> Unit) { + (context as LifecycleOwner).lifecycleScope.launch(Dispatchers.IO) { f() } + } + + private fun ui(f: suspend () -> Unit) { + (context as LifecycleOwner).lifecycleScope.launch(Dispatchers.Main) { f() } + } + + private suspend fun uiCtx(f: suspend () -> Unit) { + withContext(Dispatchers.Main) { f() } + } +} diff --git a/app/src/full/java/com/celzero/bravedns/adapter/WgPeersAdapter.kt b/app/src/full/java/com/celzero/bravedns/adapter/WgPeersAdapter.kt index 7c9a29197..154ca4125 100644 --- a/app/src/full/java/com/celzero/bravedns/adapter/WgPeersAdapter.kt +++ b/app/src/full/java/com/celzero/bravedns/adapter/WgPeersAdapter.kt @@ -26,9 +26,9 @@ import androidx.recyclerview.widget.RecyclerView import com.celzero.bravedns.R import com.celzero.bravedns.databinding.ListItemWgPeersBinding import com.celzero.bravedns.service.WireguardManager -import com.celzero.bravedns.service.WireguardManager.WARP_ID import com.celzero.bravedns.ui.dialog.WgAddPeerDialog import com.celzero.bravedns.util.UIUtils +import com.celzero.bravedns.util.Utilities.tos import com.celzero.bravedns.wireguard.Peer import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.Dispatchers @@ -61,9 +61,6 @@ class WgPeersAdapter( RecyclerView.ViewHolder(b.root) { fun update(wgPeer: Peer) { - if (configId == WARP_ID) { - handleWarpPeers() - } if (wgPeer.getEndpoint().isPresent) { b.endpointText.text = wgPeer.getEndpoint().get().toString() } else { @@ -86,16 +83,11 @@ class WgPeersAdapter( b.persistentKeepaliveText.visibility = View.GONE b.persistentKeepaliveLabel.visibility = View.GONE } - b.publicKeyText.text = wgPeer.getPublicKey().base64() + b.publicKeyText.text = wgPeer.getPublicKey().base64().tos() b.peerEdit.setOnClickListener { openEditPeerDialog(wgPeer) } b.peerDelete.setOnClickListener { showDeleteInterfaceDialog(wgPeer) } } - - private fun handleWarpPeers() { - b.peerEdit.visibility = View.GONE - b.peerDelete.visibility = View.GONE - } } private fun openEditPeerDialog(wgPeer: Peer) { diff --git a/app/src/full/java/com/celzero/bravedns/customdownloader/IIpInfoDownload.kt b/app/src/full/java/com/celzero/bravedns/customdownloader/IIpInfoDownload.kt new file mode 100644 index 000000000..84e37a921 --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/customdownloader/IIpInfoDownload.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2025 RethinkDNS and its authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.celzero.bravedns.customdownloader + +import com.google.gson.JsonObject +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Streaming + +interface IIpInfoDownload { + + @GET("{ipAddress}") + @Streaming + suspend fun downloadIpInfo( + @Path("ipAddress") ipAddress: String, + ): Response? + +} diff --git a/app/src/full/java/com/celzero/bravedns/customdownloader/IpInfoDownloader.kt b/app/src/full/java/com/celzero/bravedns/customdownloader/IpInfoDownloader.kt new file mode 100644 index 000000000..f8c781328 --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/customdownloader/IpInfoDownloader.kt @@ -0,0 +1,135 @@ +/* + * Copyright 2025 RethinkDNS and its authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.celzero.bravedns.customdownloader + +import Logger +import Logger.LOG_TAG_DOWNLOAD +import com.celzero.bravedns.RethinkDnsApplication.Companion.DEBUG +import com.celzero.bravedns.database.IpInfo +import com.celzero.bravedns.database.IpInfoRepository +import com.celzero.bravedns.service.PersistentState +import com.google.gson.Gson +import com.google.gson.JsonObject +import com.google.gson.JsonSyntaxException +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import retrofit2.converter.gson.GsonConverterFactory +import kotlin.math.pow + +object IpInfoDownloader: KoinComponent { + + private val persistentState by inject() + private val db by inject() + + private const val TAG = "IpInfoDownloader" + private var retryAfterTimestamp = 0L + private var retryAttemptCount = 0 + private const val HTTP_TOO_MANY_REQUEST_CODE = 429 + private const val COOL_DOWN_PERIOD_MILLIS: Long = 1 * 60 * 60 * 1000 // 1 hour + + suspend fun fetchIpInfoIfRequired(ipToLookup: String) { + if (!persistentState.downloadIpInfo) { + Logger.vv(LOG_TAG_DOWNLOAD, "$TAG; download is disabled") + return + } + + // check in database whether the ip info is already downloaded + val ipInfo = db.getIpInfo(ipToLookup) + if (ipInfo != null) { + Logger.vv(LOG_TAG_DOWNLOAD, "$TAG; already available, skip download") + return + } + + Logger.vv(LOG_TAG_DOWNLOAD, "$TAG; not present, proceed download...") + val downloadSuccessful = performIpInfoDownload(ipToLookup) + Logger.d(LOG_TAG_DOWNLOAD, "$TAG; download complete, success? $downloadSuccessful") + } + + private suspend fun performIpInfoDownload(ipToLookup: String): Boolean { + if (DEBUG) OkHttpDebugLogging.enableHttp2() + if (DEBUG) OkHttpDebugLogging.enableTaskRunner() + + if (System.currentTimeMillis() < retryAfterTimestamp) { + val remainingTime = retryAfterTimestamp - System.currentTimeMillis() + Logger.i(LOG_TAG_DOWNLOAD, "$TAG; too many req, no attempt to download for $remainingTime") + return false + } + + val retrofitInstance = RetrofitManager.getIpInfoBaseBuilder(persistentState.routeRethinkInRethink) + .addConverterFactory(GsonConverterFactory.create()) + .build() + + val ipInfoDownloadApi = retrofitInstance.create(IIpInfoDownload::class.java) + + return try { + val downloadResponse = ipInfoDownloadApi.downloadIpInfo(ipToLookup) + // in case if the response error is 429 do not send requests for next 5 minutes + // if the response is 429 for second time, then exponentially increase the break time + if (downloadResponse == null) { + Logger.i(LOG_TAG_DOWNLOAD, "$TAG; download failed: response is null") + return false + } + + if (downloadResponse.isSuccessful) { + val jsonResponse = downloadResponse.body() + if (jsonResponse == null) { + Logger.i(LOG_TAG_DOWNLOAD, "$TAG; download failed: response body is null") + return false + } + val ipInfo = parseIpInfoFromJson(jsonResponse) + if (ipInfo == null) { + Logger.i(LOG_TAG_DOWNLOAD, "$TAG; download failed: ip is null") + return false + } + db.insertAsync(ipInfo) + Logger.i(LOG_TAG_DOWNLOAD, "$TAG; download successful: $jsonResponse") + true + } else { + if (downloadResponse.code() == HTTP_TOO_MANY_REQUEST_CODE) { + Logger.i(LOG_TAG_DOWNLOAD, "$TAG; download failed: ${downloadResponse.code()} ${downloadResponse.message()}") + var coolDownPeriodMillis: Long = COOL_DOWN_PERIOD_MILLIS + if (retryAttemptCount > 1) { + Logger.i(LOG_TAG_DOWNLOAD, "$TAG; download failed: too many attempts") + // increase the break time exponentially + coolDownPeriodMillis = (2.0.pow(retryAttemptCount.toDouble()) * 60 * 1000).toLong() + } + retryAfterTimestamp = System.currentTimeMillis() + coolDownPeriodMillis + retryAttemptCount++ + } + + Logger.i(LOG_TAG_DOWNLOAD, "$TAG; download failed: ${downloadResponse.code()} ${downloadResponse.message()}") + false + } + } catch (e: Exception) { + Logger.i(LOG_TAG_DOWNLOAD, "$TAG; err while download: ${e.localizedMessage}") + false + } + } + + private fun parseIpInfoFromJson(ipInfoJson: JsonObject): IpInfo? { + try { + val ipInfoJsonString = ipInfoJson.toString() + val ipInfo = Gson().fromJson(ipInfoJsonString, IpInfo::class.java) + ipInfo.createdTs = System.currentTimeMillis() + return ipInfo + } catch (e: JsonSyntaxException) { + Logger.w(LOG_TAG_DOWNLOAD, "$TAG; err parsing ip info: ${e.message}") + } catch (e: Exception) { + Logger.w(LOG_TAG_DOWNLOAD, "$TAG; err parsing ip info: ${e.message}") + } + return null + } +} diff --git a/app/src/full/java/com/celzero/bravedns/customdownloader/LocalBlocklistCoordinator.kt b/app/src/full/java/com/celzero/bravedns/customdownloader/LocalBlocklistCoordinator.kt index 1c4a81264..77ac479e9 100644 --- a/app/src/full/java/com/celzero/bravedns/customdownloader/LocalBlocklistCoordinator.kt +++ b/app/src/full/java/com/celzero/bravedns/customdownloader/LocalBlocklistCoordinator.kt @@ -35,7 +35,7 @@ import com.celzero.bravedns.data.AppConfig import com.celzero.bravedns.download.BlocklistDownloadHelper import com.celzero.bravedns.service.PersistentState import com.celzero.bravedns.service.RethinkBlocklistManager -import com.celzero.bravedns.ui.HomeScreenActivity +import com.celzero.bravedns.ui.activity.AppLockActivity import com.celzero.bravedns.util.Constants import com.celzero.bravedns.util.Constants.Companion.INIT_TIME_MS import com.celzero.bravedns.util.Constants.Companion.LOCAL_BLOCKLIST_DOWNLOAD_FOLDER_NAME @@ -80,6 +80,7 @@ class LocalBlocklistCoordinator(val context: Context, workerParams: WorkerParame private const val DOWNLOAD_NOTIFICATION_TAG = "DOWNLOAD_ALERTS" private const val DOWNLOAD_NOTIFICATION_ID = 110 + private const val MAX_RETRY_COUNT = 3 } override suspend fun doWork(): Result { @@ -251,7 +252,7 @@ class LocalBlocklistCoordinator(val context: Context, workerParams: WorkerParame try { // create okhttp client with base url val retrofit = - getBlocklistBaseBuilder(retryCount).build().create(IBlocklistDownload::class.java) + getBlocklistBaseBuilder(persistentState.routeRethinkInRethink).build().create(IBlocklistDownload::class.java) Logger.i(LOG_TAG_DOWNLOAD, "Downloading file: $fileName, url: $url") val response = retrofit.downloadLocalBlocklistFile(url, persistentState.appVersion, "") if (response?.isSuccessful == true) { @@ -276,7 +277,7 @@ class LocalBlocklistCoordinator(val context: Context, workerParams: WorkerParame private fun isRetryRequired(retryCount: Int): Boolean { Logger.i(LOG_TAG_DOWNLOAD, "Retry count: $retryCount") - return retryCount < RetrofitManager.Companion.OkHttpDnsType.entries.size - 1 + return retryCount < MAX_RETRY_COUNT } private fun downloadFile(context: Context, body: ResponseBody?, fileName: String): Boolean { @@ -478,8 +479,8 @@ class LocalBlocklistCoordinator(val context: Context, workerParams: WorkerParame private fun getPendingIntent(context: Context): PendingIntent { return Utilities.getActivityPendingIntent( context, - Intent(context, HomeScreenActivity::class.java), - PendingIntent.FLAG_UPDATE_CURRENT, + Intent(context, AppLockActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, mutable = false ) } diff --git a/app/src/full/java/com/celzero/bravedns/customdownloader/RemoteBlocklistCoordinator.kt b/app/src/full/java/com/celzero/bravedns/customdownloader/RemoteBlocklistCoordinator.kt index fb819bee4..35c5ab77d 100644 --- a/app/src/full/java/com/celzero/bravedns/customdownloader/RemoteBlocklistCoordinator.kt +++ b/app/src/full/java/com/celzero/bravedns/customdownloader/RemoteBlocklistCoordinator.kt @@ -43,6 +43,7 @@ class RemoteBlocklistCoordinator(val context: Context, workerParams: WorkerParam companion object { const val REMOTE_DOWNLOAD_WORKER = "CUSTOM_DOWNLOAD_WORKER_REMOTE" + private const val TOTAL_RETRY_COUNT = 3 private val BLOCKLIST_DOWNLOAD_TIMEOUT_MS = TimeUnit.MINUTES.toMillis(10) } @@ -94,7 +95,7 @@ class RemoteBlocklistCoordinator(val context: Context, workerParams: WorkerParam Logger.i(LOG_TAG_DOWNLOAD, "Download remote blocklist: $timestamp") try { val retrofit = - RetrofitManager.getBlocklistBaseBuilder(retryCount) + RetrofitManager.getBlocklistBaseBuilder(persistentState.routeRethinkInRethink) .addConverterFactory(GsonConverterFactory.create()) .build() val retrofitInterface = retrofit.create(IBlocklistDownload::class.java) @@ -126,7 +127,7 @@ class RemoteBlocklistCoordinator(val context: Context, workerParams: WorkerParam } private fun isRetryRequired(retryCount: Int): Boolean { - return retryCount < RetrofitManager.Companion.OkHttpDnsType.entries.size - 1 + return retryCount < TOTAL_RETRY_COUNT } private suspend fun saveRemoteFile(jsonObject: JsonObject?, timestamp: Long): Boolean { diff --git a/app/src/full/java/com/celzero/bravedns/customdownloader/RetrofitManager.kt b/app/src/full/java/com/celzero/bravedns/customdownloader/RetrofitManager.kt index bda1981e6..bc4bf49da 100644 --- a/app/src/full/java/com/celzero/bravedns/customdownloader/RetrofitManager.kt +++ b/app/src/full/java/com/celzero/bravedns/customdownloader/RetrofitManager.kt @@ -16,11 +16,14 @@ package com.celzero.bravedns.customdownloader import Logger +import com.celzero.bravedns.service.PersistentState import com.celzero.bravedns.util.Constants import okhttp3.Dns import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.dnsoverhttps.DnsOverHttps +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import retrofit2.Retrofit import java.net.InetAddress import java.util.concurrent.TimeUnit @@ -36,41 +39,45 @@ class RetrofitManager { FALLBACK_DNS } - fun getBlocklistBaseBuilder(dnsId: Int): Retrofit.Builder { + fun getBlocklistBaseBuilder(isRinRActive: Boolean): Retrofit.Builder { return Retrofit.Builder() .baseUrl(Constants.DOWNLOAD_BASE_URL) - .client(okHttpClient(dnsId)) + .client(okHttpClient(isRinRActive)) } - fun getWarpBaseBuilder(dnsId: Int): Retrofit.Builder { + fun getTcpProxyBaseBuilder(isRinRActive: Boolean): Retrofit.Builder { return Retrofit.Builder() - .baseUrl(Constants.DOWNLOAD_BASE_URL) - .client(okHttpClient(dnsId)) + .baseUrl(Constants.TCP_PROXY_BASE_URL) + .client(okHttpClient(isRinRActive)) } - fun getTcpProxyBaseBuilder(dnsId: Int): Retrofit.Builder { + fun getIpInfoBaseBuilder(isRinRActive: Boolean): Retrofit.Builder { return Retrofit.Builder() - .baseUrl(Constants.TCP_PROXY_BASE_URL) - .client(okHttpClient(dnsId)) + .baseUrl(Constants.IP_INFO_BASE_URL) + .client(okHttpClient(isRinRActive)) } - fun okHttpClient(dnsId: Int = 0): OkHttpClient { + fun okHttpClient(isRinRActive: Boolean): OkHttpClient { val b = OkHttpClient.Builder() b.connectTimeout(1, TimeUnit.MINUTES) b.readTimeout(20, TimeUnit.MINUTES) b.writeTimeout(5, TimeUnit.MINUTES) b.retryOnConnectionFailure(true) // If unset, the system-wide default DNS will be used. - customDns(dnsId, b.build())?.let { b.dns(it) } + // no need to add custom dns if rinr is not active, as the connections will be routed + // through the default dns + if (isRinRActive) { + customDns(b.build())?.let { b.dns(it) } + } return b.build() } // As of now, quad9 is used as default dns in okhttp client. - private fun customDns(dnsId: Int, bootstrapClient: OkHttpClient): Dns? { - enumValues().forEach { _ -> + private fun customDns(bootstrapClient: OkHttpClient): Dns? { + enumValues().forEach { it -> try { - when (dnsId) { - OkHttpDnsType.DEFAULT.ordinal -> { + when (it) { + OkHttpDnsType.DEFAULT -> { return DnsOverHttps.Builder() .client(bootstrapClient) .url("https://dns.quad9.net/dns-query".toHttpUrl()) @@ -83,7 +90,7 @@ class RetrofitManager { .includeIPv6(true) .build() } - OkHttpDnsType.CLOUDFLARE.ordinal -> { + OkHttpDnsType.CLOUDFLARE -> { return DnsOverHttps.Builder() .client(bootstrapClient) .url("https://cloudflare-dns.com/dns-query".toHttpUrl()) @@ -96,7 +103,7 @@ class RetrofitManager { .includeIPv6(true) .build() } - OkHttpDnsType.GOOGLE.ordinal -> { + OkHttpDnsType.GOOGLE -> { return DnsOverHttps.Builder() .client(bootstrapClient) .url("https://dns.google/dns-query".toHttpUrl()) @@ -109,20 +116,16 @@ class RetrofitManager { .includeIPv6(true) .build() } - OkHttpDnsType.SYSTEM_DNS.ordinal -> { + OkHttpDnsType.SYSTEM_DNS -> { return Dns.SYSTEM } - OkHttpDnsType.FALLBACK_DNS.ordinal -> { + OkHttpDnsType.FALLBACK_DNS -> { // todo: return retrieved system dns return null } } } catch (e: Exception) { - Logger.crash( - Logger.LOG_TAG_DOWNLOAD, - "err while getting custom dns: ${e.message}", - e - ) + Logger.crash(Logger.LOG_TAG_DOWNLOAD, "err; custom dns: ${e.message}", e) } } return null diff --git a/app/src/full/java/com/celzero/bravedns/download/AppDownloadManager.kt b/app/src/full/java/com/celzero/bravedns/download/AppDownloadManager.kt index 8c6a7183c..f1434f639 100644 --- a/app/src/full/java/com/celzero/bravedns/download/AppDownloadManager.kt +++ b/app/src/full/java/com/celzero/bravedns/download/AppDownloadManager.kt @@ -79,7 +79,7 @@ class AppDownloadManager( suspend fun isDownloadRequired(type: DownloadType) { downloadRequired.postValue(DownloadManagerStatus.IN_PROGRESS) val ts = getCurrentBlocklistTimestamp(type) - val response = checkBlocklistUpdate(ts, persistentState.appVersion, retryCount = 0) + val response = checkBlocklistUpdate(ts, persistentState.appVersion, retryCount = 0, persistentState.routeRethinkInRethink) // if received response for update is null if (response == null) { Logger.w( @@ -166,7 +166,7 @@ class AppDownloadManager( return DownloadManagerStatus.FAILURE } - val response = checkBlocklistUpdate(currentTs, persistentState.appVersion, retryCount = 0) + val response = checkBlocklistUpdate(currentTs, persistentState.appVersion, retryCount = 0, persistentState.routeRethinkInRethink) // if received response for update is null if (response == null) { Logger.w( @@ -237,7 +237,7 @@ class AppDownloadManager( suspend fun downloadRemoteBlocklist(currentTs: Long, isRedownload: Boolean): Boolean { - val response = checkBlocklistUpdate(currentTs, persistentState.appVersion, retryCount = 0) + val response = checkBlocklistUpdate(currentTs, persistentState.appVersion, retryCount = 0, persistentState.routeRethinkInRethink) // if received response for update is null if (response == null) { Logger.w(LOG_TAG_DNS, "remote blocklist update check is null") diff --git a/app/src/full/java/com/celzero/bravedns/download/BlocklistDownloadHelper.kt b/app/src/full/java/com/celzero/bravedns/download/BlocklistDownloadHelper.kt index 14836f859..b9dc9f073 100644 --- a/app/src/full/java/com/celzero/bravedns/download/BlocklistDownloadHelper.kt +++ b/app/src/full/java/com/celzero/bravedns/download/BlocklistDownloadHelper.kt @@ -39,12 +39,25 @@ class BlocklistDownloadHelper { ) companion object { + private const val TOTAL_RETRY_COUNT = 3 + fun logd(message: String) { + Logger.d(LOG_TAG_DOWNLOAD, message) + } + + fun logi(message: String) { + Logger.i(LOG_TAG_DOWNLOAD, message) + } + + fun logw(message: String, exception: Exception) { + Logger.w(LOG_TAG_DOWNLOAD, message, exception) + } + fun isDownloadComplete(context: Context, timestamp: Long): Boolean { var result = false var total: Int? = 0 var dir: File? = null try { - Logger.d(LOG_TAG_DOWNLOAD, "Local block list validation: $timestamp") + logd("Local block list validation: $timestamp") dir = File(getExternalFilePath(context, timestamp.toString())) total = if (dir.isDirectory) { @@ -54,17 +67,10 @@ class BlocklistDownloadHelper { } result = Constants.ONDEVICE_BLOCKLISTS_ADM.count() == total } catch (ignored: Exception) { - Logger.w( - LOG_TAG_DOWNLOAD, - "Local block list validation failed: ${ignored.message}", - ignored - ) + logw("Local block list validation failed: ${ignored.message}", ignored) } - Logger.d( - LOG_TAG_DOWNLOAD, - "Valid on-device blocklist ($timestamp) download? $result, files: $total, dir? ${dir?.isDirectory}" - ) + logd("on-device blocklist($timestamp) download? $result, files: $total, dir? ${dir?.isDirectory}") return result } @@ -86,7 +92,7 @@ class BlocklistDownloadHelper { Constants.ONDEVICE_BLOCKLIST_DOWNLOAD_PATH } val dir = File(context.getExternalFilesDir(null).toString() + path + timestamp) - Logger.d(LOG_TAG_DOWNLOAD, "deleteOldFiles, File : ${dir.path}, ${dir.isDirectory}") + logd("deleteOldFiles, File : ${dir.path}, ${dir.isDirectory}") deleteRecursive(dir) } @@ -95,9 +101,7 @@ class BlocklistDownloadHelper { if (!dir.exists()) return dir.listFiles()?.forEach { - Logger.d( - LOG_TAG_DOWNLOAD, - "Delete blocklist list residue for $which, dir: ${it.name}" + logd("delete blocklist list residue for $which, dir: ${it.name}" ) // delete all the dir other than current timestamp dir if (it.name != timestamp.toString()) { @@ -106,14 +110,6 @@ class BlocklistDownloadHelper { } } - fun deleteFromCanonicalPath(context: Context) { - val canonicalPath = - File( - blocklistCanonicalPath(context, Constants.LOCAL_BLOCKLIST_DOWNLOAD_FOLDER_NAME) - ) - deleteRecursive(canonicalPath) - } - fun getExternalFilePath(context: Context, timestamp: String): String { return context.getExternalFilesDir(null).toString() + Constants.ONDEVICE_BLOCKLIST_DOWNLOAD_PATH + @@ -136,18 +132,16 @@ class BlocklistDownloadHelper { suspend fun checkBlocklistUpdate( timestamp: Long, vcode: Int, - retryCount: Int + retryCount: Int, + isRinRActive: Boolean ): BlocklistUpdateServerResponse? { try { val retrofit = - RetrofitManager.getBlocklistBaseBuilder(retryCount) + RetrofitManager.getBlocklistBaseBuilder(isRinRActive) .addConverterFactory(GsonConverterFactory.create()) .build() val retrofitInterface = retrofit.create(IBlocklistDownload::class.java) - Logger.i( - LOG_TAG_DOWNLOAD, - "downloadAvailabilityCheck: ${Constants.ONDEVICE_BLOCKLIST_UPDATE_CHECK_QUERYPART_1}, ${Constants.ONDEVICE_BLOCKLIST_UPDATE_CHECK_QUERYPART_2}, $vcode, $timestamp" - ) + logi("downloadAvailabilityCheck: ${Constants.ONDEVICE_BLOCKLIST_UPDATE_CHECK_QUERYPART_1}, ${Constants.ONDEVICE_BLOCKLIST_UPDATE_CHECK_QUERYPART_2}, $vcode, $timestamp") val response = retrofitInterface.downloadAvailabilityCheck( Constants.ONDEVICE_BLOCKLIST_UPDATE_CHECK_QUERYPART_1, @@ -155,32 +149,26 @@ class BlocklistDownloadHelper { timestamp, vcode ) - Logger.i( - LOG_TAG_DOWNLOAD, - "downloadAvailabilityCheck: $response, $retryCount, $vcode, $timestamp" - ) + logi("downloadAvailabilityCheck: $response, $retryCount, $vcode, $timestamp") if (response?.isSuccessful == true) { val r = response.body()?.toString()?.let { JSONObject(it) } return processCheckDownloadResponse(r) } } catch (ex: Exception) { - Logger.crash(LOG_TAG_DOWNLOAD, "exception in checkBlocklistUpdate: ${ex.message}", ex) + logw("exception in checkBlocklistUpdate: ${ex.message}", ex) } - Logger.i( - LOG_TAG_DOWNLOAD, - "downloadAvailabilityCheck: failed, returning null, $retryCount" - ) + logi("downloadAvailabilityCheck: failed, returning null, $retryCount") return if (isRetryRequired(retryCount)) { - Logger.i(LOG_TAG_DOWNLOAD, "retrying the downloadAvailabilityCheck") - checkBlocklistUpdate(timestamp, vcode, retryCount + 1) + logi("retrying the downloadAvailabilityCheck") + checkBlocklistUpdate(timestamp, vcode, retryCount + 1, isRinRActive) } else { - Logger.i(LOG_TAG_DOWNLOAD, "retry count exceeded, returning null") + logi("retry count exceeded, returning null") null } } private fun isRetryRequired(retryCount: Int): Boolean { - return retryCount < RetrofitManager.Companion.OkHttpDnsType.entries.size - 1 + return retryCount < TOTAL_RETRY_COUNT } private fun processCheckDownloadResponse( @@ -190,21 +178,15 @@ class BlocklistDownloadHelper { try { val version = response.optInt(Constants.JSON_VERSION, 0) - Logger.d( - LOG_TAG_DOWNLOAD, - "client onResponse for refresh blocklist files: $version" - ) + logd("client onResponse for refresh blocklist files: $version") - val shouldUpdate = response.optBoolean(Constants.JSON_UPDATE, false) + val hasUpdate = response.optBoolean(Constants.JSON_UPDATE, false) val timestamp = response.optLong(Constants.JSON_LATEST, INIT_TIME_MS) - Logger.i( - LOG_TAG_DOWNLOAD, - "response for blocklist update check: version: $version, update? $shouldUpdate, timestamp: $timestamp" - ) + logi("response for blocklist update check: version: $version, update? $hasUpdate, timestamp: $timestamp") - return BlocklistUpdateServerResponse(version, shouldUpdate, timestamp) + return BlocklistUpdateServerResponse(version, hasUpdate, timestamp) } catch (e: JSONException) { - Logger.crash(LOG_TAG_DOWNLOAD, "Error in parsing the response: ${e.message}", e) + logw("err parsing the response: ${e.message}", e) } return null } diff --git a/app/src/full/java/com/celzero/bravedns/download/FileHandleWorker.kt b/app/src/full/java/com/celzero/bravedns/download/FileHandleWorker.kt index 60fca49a9..919c8cf11 100644 --- a/app/src/full/java/com/celzero/bravedns/download/FileHandleWorker.kt +++ b/app/src/full/java/com/celzero/bravedns/download/FileHandleWorker.kt @@ -21,6 +21,7 @@ import android.content.Context import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import androidx.work.workDataOf +import com.celzero.bravedns.download.BlocklistDownloadHelper.Companion.deleteBlocklistResidue import com.celzero.bravedns.download.BlocklistDownloadHelper.Companion.deleteOldFiles import com.celzero.bravedns.service.PersistentState import com.celzero.bravedns.service.RethinkBlocklistManager @@ -87,7 +88,6 @@ class FileHandleWorker(val context: Context, workerParameters: WorkerParameters) return false } - BlocklistDownloadHelper.deleteFromCanonicalPath(context) val dir = File(BlocklistDownloadHelper.getExternalFilePath(context, timestamp.toString())) if (!dir.isDirectory) { @@ -139,10 +139,18 @@ class FileHandleWorker(val context: Context, workerParameters: WorkerParameters) val result = updateTagsToDb(timestamp) updatePersistenceOnCopySuccess(timestamp) + // delete the old files in the external directory (downloaded by the download manager) deleteOldFiles(context, timestamp, RethinkBlocklistManager.DownloadType.LOCAL) + // delete the residue files in the app data directory (local_blocklist) + deleteBlocklistResidue( + context, + Constants.REMOTE_BLOCKLIST_DOWNLOAD_FOLDER_NAME, + timestamp + ) + Logger.i(LOG_TAG_DOWNLOAD, "FileHandleWorker, copyFiles success? $result") return true } catch (e: Exception) { - Logger.e(LOG_TAG_DOWNLOAD, "AppDownloadManager Copy exception: ${e.message}", e) + Logger.e(LOG_TAG_DOWNLOAD, "FileHandleWorker Copy exception: ${e.message}", e) } return false } @@ -188,10 +196,10 @@ class FileHandleWorker(val context: Context, workerParameters: WorkerParameters) "tdmd5: $tdmd5, rdmd5: $rdmd5, remotetd: $remoteTdmd5, remoterd: $remoteRdmd5" ) val isDownloadValid = tdmd5 == remoteTdmd5 && rdmd5 == remoteRdmd5 - Logger.i(LOG_TAG_DOWNLOAD, "AppDownloadManager, isDownloadValid? $isDownloadValid") + Logger.i(LOG_TAG_DOWNLOAD, "FileHandleWorker, isDownloadValid? $isDownloadValid") return isDownloadValid } catch (e: Exception) { - Logger.e(LOG_TAG_DOWNLOAD, "AppDownloadManager, isDownloadValid err: ${e.message}", e) + Logger.e(LOG_TAG_DOWNLOAD, "FileHandleWorker, isDownloadValid err: ${e.message}", e) } return false } diff --git a/app/src/full/java/com/celzero/bravedns/receiver/NotificationActionReceiver.kt b/app/src/full/java/com/celzero/bravedns/receiver/NotificationActionReceiver.kt index 97b411b04..f33953eea 100644 --- a/app/src/full/java/com/celzero/bravedns/receiver/NotificationActionReceiver.kt +++ b/app/src/full/java/com/celzero/bravedns/receiver/NotificationActionReceiver.kt @@ -46,7 +46,7 @@ class NotificationActionReceiver : BroadcastReceiver(), KoinComponent { override fun onReceive(context: Context, intent: Intent) { // TODO - Move the NOTIFICATION_ACTIONs value to enum val action: String? = intent.getStringExtra(Constants.NOTIFICATION_ACTION) - Logger.i(LOG_TAG_VPN, "Received notification action: $action") + Logger.i(LOG_TAG_VPN, "received notification action: $action") val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager when (action) { OrbotHelper.ORBOT_NOTIFICATION_ACTION_TEXT -> { @@ -73,7 +73,10 @@ class NotificationActionReceiver : BroadcastReceiver(), KoinComponent { } Constants.NOTIF_ACTION_NEW_APP_ALLOW -> { val uid = intent.getIntExtra(Constants.NOTIF_INTENT_EXTRA_APP_UID, Int.MIN_VALUE) - if (uid < 0) return + if (uid < 0) { + Logger.i(LOG_TAG_VPN, "Invalid uid: $uid, on new app allow, ignoring") + return + } manager.cancel(NOTIF_CHANNEL_ID_FIREWALL_ALERTS, uid) @@ -81,7 +84,10 @@ class NotificationActionReceiver : BroadcastReceiver(), KoinComponent { } Constants.NOTIF_ACTION_NEW_APP_DENY -> { val uid = intent.getIntExtra(Constants.NOTIF_INTENT_EXTRA_APP_UID, Int.MIN_VALUE) - if (uid < 0) return + if (uid < 0) { + Logger.i(LOG_TAG_VPN, "Invalid uid: $uid, on new app deny, ignoring") + return + } manager.cancel(NOTIF_CHANNEL_ID_FIREWALL_ALERTS, uid) @@ -95,7 +101,7 @@ class NotificationActionReceiver : BroadcastReceiver(), KoinComponent { } private fun stopVpn(context: Context) { - VpnController.stop(context) + VpnController.stop("notif", context) } private fun pauseApp(context: Context) { diff --git a/app/src/full/java/com/celzero/bravedns/receiver/UserPresentReceiver.kt b/app/src/full/java/com/celzero/bravedns/receiver/UserPresentReceiver.kt new file mode 100644 index 000000000..4d59cc668 --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/receiver/UserPresentReceiver.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2025 RethinkDNS and its authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.celzero.bravedns.receiver + +import Logger.LOG_TAG_UI +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.celzero.bravedns.service.VpnController + +/** + * BroadcastReceiver to handle screen state changes (on/off) and user presence. + * Informs the VpnController about these events. + */ +class UserPresentReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (Intent.ACTION_SCREEN_OFF == intent.action) { + VpnController.screenLock() + Logger.v(LOG_TAG_UI, "user-present: action screen off, inform vpn service") + } else if (Intent.ACTION_SCREEN_ON == intent.action || Intent.ACTION_USER_PRESENT == intent.action) { + VpnController.screenUnlock() + Logger.v(LOG_TAG_UI, "user-present: action screen on, inform vpn service") + } else { + Logger.v(LOG_TAG_UI, "user-present: unknown action ${intent.action}, skipping") + } + } +} diff --git a/app/src/full/java/com/celzero/bravedns/receiver/VPNControlReceiver.kt b/app/src/full/java/com/celzero/bravedns/receiver/VPNControlReceiver.kt index fba1a2b03..c29e7f9ab 100644 --- a/app/src/full/java/com/celzero/bravedns/receiver/VPNControlReceiver.kt +++ b/app/src/full/java/com/celzero/bravedns/receiver/VPNControlReceiver.kt @@ -1,5 +1,5 @@ /* - * Copyright 2020 RethinkDNS and its authors + * Copyright 2025 RethinkDNS and its authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,45 +16,174 @@ package com.celzero.bravedns.receiver - import Logger import Logger.LOG_TAG_VPN import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.net.VpnService +import com.celzero.bravedns.RethinkDnsApplication.Companion.DEBUG +import com.celzero.bravedns.service.PersistentState import com.celzero.bravedns.service.VpnController +import com.celzero.bravedns.util.Utilities.isAtleastT +import com.celzero.bravedns.util.Utilities.isAtleastU import org.koin.core.component.KoinComponent +import org.koin.core.component.inject -class VPNControlReceiver : BroadcastReceiver(), KoinComponent { +class VpnControlReceiver: BroadcastReceiver(), KoinComponent { + private val persistentState by inject() + companion object { + private const val TAG = "VpnCtrlRecr" + private const val ACTION_START = "com.celzero.bravedns.intent.action.VPN_START" + private const val ACTION_STOP = "com.celzero.bravedns.intent.action.VPN_STOP" + private const val STOP_REASON = "tasker_stop" + } override fun onReceive(context: Context, intent: Intent) { + if (intent.action == null) { + Logger.w(LOG_TAG_VPN, "$TAG Received null action intent") + return + } + + val allowedPackages = persistentState.appTriggerPackages.split(",").map { it.trim() }.toSet() + Logger.d(LOG_TAG_VPN, "$TAG Allowed packages: $allowedPackages") + if (allowedPackages.isEmpty()) { + Logger.i(LOG_TAG_VPN, "$TAG No allowed packages, ignoring intent") + return + } + + var callerPkg = getCallerPkg(context, intent) + if (callerPkg == null) { + Logger.w(LOG_TAG_VPN, "$TAG Received intent with null package name") + return + } + + if (!allowedPackages.contains(callerPkg)) { + Logger.w(LOG_TAG_VPN, "$TAG Received intent from untrusted package: $callerPkg") + return + } + if (intent.action == ACTION_START) { - val prepareVpnIntent: Intent? = - try { - Logger.i(LOG_TAG_VPN, "Attempting to prepare VPN before starting") - VpnService.prepare(context) - } catch (e: NullPointerException) { - // This shouldn't happen normally as Broadcast Intent sender apps like Tasker won't come up as early as Always-on VPNs - // Context can be null in case of auto-restart VPNs: https://stackoverflow.com/questions/73147633/getting-null-in-context-while-auto-restart-with-broadcast-receiver-in-android-ap - Logger.w(LOG_TAG_VPN, "Device does not support system-wide VPN mode") - return - } - if (prepareVpnIntent == null) { - Logger.i(LOG_TAG_VPN, "VPN is prepared, invoking start") - VpnController.start(context) + handleVpnStart(context) + } + if (intent.action == ACTION_STOP) { + handleVpnStop(context) + } + } + + private fun handleVpnStart(context: Context) { + if (VpnController.isOn()) { + Logger.i(LOG_TAG_VPN, "$TAG VPN is already running, ignoring start intent") + return + } + val prepareVpnIntent: Intent? = + try { + Logger.i(LOG_TAG_VPN, "$TAG Attempting to prepare VPN before starting") + VpnService.prepare(context) + } catch (ignored: NullPointerException) { + // This shouldn't happen normally as Broadcast Intent sender apps like Tasker + // won't come up as early as Always-on VPNs + // Context can be null in case of auto-restart VPNs: + // ref stackoverflow.com/questions/73147633/getting-null-in-context-while-auto-restart-with-broadcast-receiver-in-android-ap + Logger.w(LOG_TAG_VPN, "$TAG Device does not support system-wide VPN mode") return } + if (prepareVpnIntent == null) { + Logger.i(LOG_TAG_VPN, "$TAG VPN is prepared, invoking start") + VpnController.start(context) + return } - if (intent.action == ACTION_STOP) { - Logger.i(LOG_TAG_VPN, "VPN stopping") - VpnController.stop(context) + } + + private fun handleVpnStop(context: Context) { + if (!VpnController.isOn()) { + Logger.i(LOG_TAG_VPN, "$TAG VPN is not running, ignoring stop intent") + return + } + if (VpnController.isAlwaysOn(context)) { + Logger.w(LOG_TAG_VPN, "$TAG VPN is always-on, ignoring stop intent") return } + + Logger.i(LOG_TAG_VPN, "$TAG VPN stopping") + VpnController.stop(STOP_REASON, context) } - companion object { - private const val ACTION_START = "com.celzero.bravedns.intent.action.VPN_START" - private const val ACTION_STOP = "com.celzero.bravedns.intent.action.VPN_STOP" + private fun getCallerPkg(context: Context, intent: Intent): String? { + // package name of the app that sent the broadcast + if (DEBUG) dumpIntent(intent) + + // expecting the intent to have a package name in the extras with key "sender" + var callerPkg = intent.getStringExtra("sender") + if (callerPkg != null) { + Logger.i(LOG_TAG_VPN, "$TAG Received intent from extra sender: $callerPkg, from sender") + return callerPkg + } + + // above 34 (Android U) sentFromPackage and sentFromUid are available + if (isAtleastU()) { + callerPkg = sentFromPackage + if (callerPkg != null) { + Logger.i(LOG_TAG_VPN, "$TAG Received intent from sentFromPackage: $callerPkg") + return callerPkg + } + val uid = sentFromUid + callerPkg = context.packageManager.getPackagesForUid(uid)?.firstOrNull() + if (callerPkg != null) { + Logger.i(LOG_TAG_VPN, "$TAG received intent from sentFromUid, $uid, $callerPkg") + return callerPkg + } + } + + // see if the intent has a package name in the extras with key "EXTRA_PACKAGE_NAME" or + // "EXTRA_INTENT", less reliable method unless the app explicitly sets it + // Intent.EXTRA_PACKAGE_NAME is typically for identifying the target package of an explicit + // intent, not usually the sender of a broadcast. However, checking it doesn't hurt. + // Intent.EXTRA_INTENT might contain package name. + callerPkg = if (isAtleastT()) { + intent.getStringExtra(Intent.EXTRA_PACKAGE_NAME) + } else { + intent.getStringExtra(Intent.EXTRA_INTENT) + } + if (callerPkg != null) { + Logger.i(LOG_TAG_VPN, "$TAG Received intent from extra sender: $callerPkg, from sender") + return callerPkg + } + + + Logger.i(LOG_TAG_VPN, "$TAG could not determine caller pkg") + return callerPkg + } + + fun dumpIntent(intent: Intent) { + val sb = StringBuilder() + sb.append("Intent content:\n") + sb.append("Action: ${intent.action}\n") + sb.append("Data: ${intent.data}\n") + sb.append("Type: ${intent.type}\n") + sb.append("Categories: ${intent.categories}\n") + sb.append("Component: ${intent.component}\n") + sb.append("Package: ${intent.`package`}\n") + sb.append("Extra :${intent.getStringExtra(Intent.EXTRA_REFERRER)}\n") + sb.append("Flags: ${intent.flags}\n") + sb.append("Source bounds: ${intent.sourceBounds}\n") + if (isAtleastT()) sb.append("Sender: ${intent.getStringExtra(Intent.EXTRA_PACKAGE_NAME)}\n") + if (isAtleastU()) { + sb.append("Sent from package: $sentFromPackage\n") + sb.append("Sent from UID: $sentFromUid\n") + } + sb.append("Extras:\n") + val extras = intent.extras + if (extras == null) { + sb.append("No extras\n") + Logger.i(LOG_TAG_VPN, "$TAG $sb ") + return + } + + for (key in extras.keySet()) { + sb.append(" $key -> ${extras.get(key)}\n") + } + + Logger.i(LOG_TAG_VPN, "$TAG $sb") } } diff --git a/app/src/full/java/com/celzero/bravedns/scheduler/BlocklistUpdateCheckJob.kt b/app/src/full/java/com/celzero/bravedns/scheduler/BlocklistUpdateCheckJob.kt index d2b0c3fa9..f2b3d0223 100644 --- a/app/src/full/java/com/celzero/bravedns/scheduler/BlocklistUpdateCheckJob.kt +++ b/app/src/full/java/com/celzero/bravedns/scheduler/BlocklistUpdateCheckJob.kt @@ -56,7 +56,8 @@ class BlocklistUpdateCheckJob(val context: Context, workerParameters: WorkerPara BlocklistDownloadHelper.checkBlocklistUpdate( timestamp, persistentState.appVersion, - retryCount = 0 + retryCount = 0, + persistentState.routeRethinkInRethink ) ?: return val updatableTs = BlocklistDownloadHelper.getDownloadableTimestamp(response) diff --git a/app/src/full/java/com/celzero/bravedns/scheduler/BugReportZipper.kt b/app/src/full/java/com/celzero/bravedns/scheduler/BugReportZipper.kt index 5e0d6bd88..afbc19f4e 100644 --- a/app/src/full/java/com/celzero/bravedns/scheduler/BugReportZipper.kt +++ b/app/src/full/java/com/celzero/bravedns/scheduler/BugReportZipper.kt @@ -27,7 +27,6 @@ import com.celzero.bravedns.service.VpnController import com.celzero.bravedns.util.Constants import com.celzero.bravedns.util.Utilities import com.google.common.io.Files -import intra.Intra import java.io.File import java.io.FileInputStream import java.io.FileNotFoundException @@ -39,12 +38,13 @@ import java.util.zip.ZipEntry import java.util.zip.ZipException import java.util.zip.ZipFile import java.util.zip.ZipOutputStream +import kotlin.compareTo object BugReportZipper { // Bug report file and directory constants - private const val BUG_REPORT_DIR_NAME = "bugreport" - private const val BUG_REPORT_ZIP_FILE_NAME = "rethinkdns.bugreport.zip" + const val BUG_REPORT_DIR_NAME = "bugreport" + const val BUG_REPORT_ZIP_FILE_NAME = "rethinkdns.bugreport.zip" private const val BUG_REPORT_FILE_NAME = "bugreport_" // maximum number of files allowed as part of bugreport zip file @@ -57,12 +57,14 @@ object BugReportZipper { fun prepare(dir: File): String { val filePath = dir.canonicalPath + File.separator + BUG_REPORT_DIR_NAME val file = File(filePath) - - if (file.exists()) { - Utilities.deleteRecursive(file) - file.mkdir() - } else { - file.mkdir() + // Use atomic operation pattern with proper error handling + val isDeleted = if (file.exists()) Utilities.deleteRecursive(file) else true + if (!isDeleted) { + Logger.w(LOG_TAG_BUG_REPORT, "failed to delete directory: ${file.absolutePath}") + } + // Use mkdirs() instead of mkdir() to handle parent directories too + if (!file.mkdirs()) { + Logger.w(LOG_TAG_BUG_REPORT, "failed to create directory: ${file.absolutePath}") } val zipFile = getZipFile(dir) ?: return constructFileName(filePath, null) @@ -79,15 +81,46 @@ object BugReportZipper { } private fun getZipFile(dir: File): ZipFile? { - return try { - ZipFile(getZipFileName(dir)) + val file = File(getZipFileName(dir)) + + if (!file.exists()) { + Logger.w(LOG_TAG_BUG_REPORT, "zip file does not exist: ${file.absolutePath}") + return null + } + + if (file.length() == 0L) { + Logger.w(LOG_TAG_BUG_REPORT, "zip file is empty: ${file.absolutePath}") + return null + } + + // check for ZIP magic bytes (PK\x03\x04) + try { + FileInputStream(file).use { fis -> + val header = ByteArray(4) + val bytesRead = fis.read(header) + if (bytesRead < 4 || + header[0] != 'P'.code.toByte() || + header[1] != 'K'.code.toByte() + ) { + Logger.w( + LOG_TAG_BUG_REPORT, + "zip file has invalid header: ${file.absolutePath}" + ) + Utilities.deleteRecursive(file) + return null + } + } + + return ZipFile(file) } catch (e: FileNotFoundException) { - Logger.w(LOG_TAG_BUG_REPORT, "File not found exception while creating zip file", e) - null + Logger.w(LOG_TAG_BUG_REPORT, "file not found exception while creating zip file", e) } catch (e: ZipException) { - Logger.w(LOG_TAG_BUG_REPORT, "Zip exception while creating zip file", e) - null + Logger.w(LOG_TAG_BUG_REPORT, "err while creating zip file", e) + Utilities.deleteRecursive(file) // delete corrupted zip file + } catch (e: Exception) { + Logger.w(LOG_TAG_BUG_REPORT, "err while creating zip file", e) } + return null } private fun constructFileName(filePath: String, fileName: String?): String { @@ -102,7 +135,8 @@ object BugReportZipper { if (directory?.entries() == null) return "" val entries = directory.entries().toList().sortedBy { it.lastModifiedTime.toMillis() } - return entries[0].name ?: "" + if (entries.isEmpty()) return "" + return entries.firstOrNull()?.name.orEmpty() } fun getZipFileName(dir: File): String { @@ -115,34 +149,102 @@ object BugReportZipper { @RequiresApi(Build.VERSION_CODES.O) fun rezipAll(dir: File, file: File) { - if (!file.exists() || file.length() <= 0) return + if (!file.exists()) { + Logger.w(LOG_TAG_BUG_REPORT, "file to zip does not exist: ${file.absolutePath}") + return + } + + if (file.length() <= 0) { + Logger.w(LOG_TAG_BUG_REPORT, "empty file, skipping: ${file.absolutePath}") + return + } val curZip = getZipFile(dir) - if (curZip == null) { - val zip = getZipFileName(dir) - FileOutputStream(zip, true).use { zf -> - ZipOutputStream(zf).use { zo -> - // Add new file to zip - addNewZipEntry(zo, file) + val zipFile = File(getZipFileName(dir)) + val tempFile = File(getTempZipFileName(dir)) + + try { + if (curZip == null) { + // create new zip file + FileOutputStream(zipFile).use { fos -> + ZipOutputStream(fos).use { zos -> + addNewZipEntry(zos, file) + } } - } - } else { - // cannot append to existing zip file, copy over and then append - // ref=(https://stackoverflow.com/a/2265206) - val tempZipFile = getTempZipFileName(dir) - curZip.use { czf -> - FileOutputStream(tempZipFile, true).use { tmp -> - ZipOutputStream(tmp).use { tzo -> - handleOlderFiles(tzo, czf, file.name) - addNewZipEntry(tzo, file) + } else { + // create temp zip with existing content + new file + FileOutputStream(tempFile).use { fos -> + ZipOutputStream(fos).use { zos -> + curZip.use { czf -> + // Check total size before adding files + val currentSize = zipFile.length() + val maxZipSize = 10 * 1024 * 1024L // 10MB limit + + if (currentSize > maxZipSize) { + // If zip is too large, keep only recent entries + val recentEntries = czf.entries().toList() + .sortedByDescending { it.lastModifiedTime.toMillis() } + .take(10) // Keep 10 most recent entries + .map { it.name } + + czf.entries().toList().forEach { entry -> + if (entry.name in recentEntries && entry.name != file.name) { + copyEntryToZip(czf, entry, zos) + } + } + } else { + handleOlderFiles(zos, czf, file.name) + } + + addNewZipEntry(zos, file) + } + } + } + + // Atomic replacement using file rename + if (zipFile.exists() && !zipFile.delete()) { + Logger.e(LOG_TAG_BUG_REPORT, "failed to delete old zip file") + return + } + + if (!tempFile.renameTo(zipFile)) { + Logger.e(LOG_TAG_BUG_REPORT, "failed to rename temp zip file") + // Try to recover by copying instead + tempFile.inputStream().use { input -> + zipFile.outputStream().use { output -> + input.copyTo(output) + } } + tempFile.delete() } } + } catch (e: Exception) { + Logger.e(LOG_TAG_BUG_REPORT, "err while updating zip file", e) + } finally { + // Clean up temp file if it exists + if (tempFile.exists()) { + tempFile.delete() + } + } + } - // delete the old zip file and rename the temp file to zip file - val zipFile = File(getZipFileName(dir)) - zipFile.delete() - File(tempZipFile).renameTo(zipFile) + // copy entry from one zip to another + private fun copyEntryToZip(sourceZip: ZipFile, entry: ZipEntry, targetZip: ZipOutputStream) { + if (entry.isDirectory) { + val zipEntry = ZipEntry(entry.name) + targetZip.putNextEntry(zipEntry) + targetZip.closeEntry() + } else { + try { + val zipEntry = ZipEntry(entry.name) + targetZip.putNextEntry(zipEntry) + sourceZip.getInputStream(entry).use { input -> + input.copyTo(targetZip) + } + targetZip.closeEntry() + } catch (e: Exception) { + Logger.w(LOG_TAG_BUG_REPORT, "err while copying entry ${entry.name}", e) + } } } @@ -153,24 +255,76 @@ object BugReportZipper { } private fun addNewZipEntry(zo: ZipOutputStream, file: File) { - if (file.isDirectory) return + if (!file.exists()) { + Logger.w(LOG_TAG_BUG_REPORT, "file does not exist: ${file.absolutePath}") + return + } - Logger.i(LOG_TAG_BUG_REPORT, "Add new file: ${file.name} to bug_report.zip") - val entry = ZipEntry(file.name) - zo.putNextEntry(entry) - FileInputStream(file).use { inStream -> copy(inStream, zo) } - zo.closeEntry() + if (file.isDirectory) { + Logger.w(LOG_TAG_BUG_REPORT, "dir cannot be added: ${file.absolutePath}") + return + } + + // skip empty files or files that exceed reasonable size + val maxSizeBytes = 10 * 1024 * 1024L // 10MB limit + if (file.length() == 0L) { + Logger.w(LOG_TAG_BUG_REPORT, "empty file skipped: ${file.name}") + return + } else if (file.length() > maxSizeBytes) { + Logger.w(LOG_TAG_BUG_REPORT, "file too large (${file.length()} bytes): ${file.name}") + return + } + + try { + val entry = ZipEntry(file.name) + zo.putNextEntry(entry) + FileInputStream(file).use { input -> + val buffer = ByteArray(8192) + var bytesRead: Int + while (input.read(buffer).also { bytesRead = it } != -1) { + zo.write(buffer, 0, bytesRead) + } + } + zo.closeEntry() + Logger.i(LOG_TAG_BUG_REPORT, "added new file to zip: ${file.name}") + } catch (e: Exception) { + Logger.e(LOG_TAG_BUG_REPORT, "err adding file to zip: ${file.name}", e) + } } + @RequiresApi(Build.VERSION_CODES.O) private fun handleOlderFiles(zo: ZipOutputStream, zipFile: ZipFile?, ignoreFileName: String) { if (zipFile == null) return val entries: Enumeration = zipFile.entries() + // get file size by checking the actual file (more reliable than internal zip size) + val zipFilePath = zipFile.name + val zipFileSize = File(zipFilePath).length() + val maxZipSize = 10 * 1024 * 1024L // 10MB limit + + // if zip file is more than 10 MB, only keep recent entries + if (zipFileSize > maxZipSize) { + Logger.i(LOG_TAG_BUG_REPORT, "Zip file size exceeds 10MB, keeping only recent entries") + + val entryList = zipFile.entries().toList().sortedByDescending { + it.lastModifiedTime.toMillis() + } + // keep only the last 5 entries or less if there are not enough entries + val keepEntries = entryList.take(5).map { it.name } + + entryList.forEach { entry -> + if (entry.name in keepEntries && entry.name != ignoreFileName) { + copyEntryToZip(zipFile, entry, zo) + } + } + return + } + while (entries.hasMoreElements()) { val e = entries.nextElement() if (ignoreFileName == e.name) { - Logger.i(LOG_TAG_BUG_REPORT, "Ignoring file to be replaced: ${e.name}") + Logger.i(LOG_TAG_BUG_REPORT, "ignoring file to be replaced: ${e.name}") continue } @@ -192,7 +346,13 @@ object BugReportZipper { fun fileWrite(inputStream: InputStream?, file: File) { if (inputStream == null) return - FileOutputStream(file, true).use { outputStream -> copy(inputStream, outputStream) } + try { + FileOutputStream(file, true).use { outputStream -> + copy(inputStream, outputStream) + } + } catch (e: Exception) { + Logger.w(LOG_TAG_BUG_REPORT, "err writing to file: ${file.name}", e) + } } @RequiresApi(Build.VERSION_CODES.R) @@ -222,14 +382,26 @@ object BugReportZipper { file.appendText(prefsDetails.toString()) val separator = "--------------------------------------------\n" file.appendText(separator) - val build = VpnController.goBuildVersion() + val build = VpnController.goBuildVersion(true) file.appendText(build) file.appendText(separator) } private fun copy(input: InputStream, output: OutputStream) { - while (input.read() != -1) { - output.write(input.readBytes()) + try { + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytesRead: Int + + while (input.read(buffer).also { bytesRead = it } != -1) { + output.write(buffer, 0, bytesRead) + } + output.flush() + } catch (e: OutOfMemoryError) { + Logger.e(LOG_TAG_BUG_REPORT, "out of memory while copying file, ${e.message}") + } catch (e: SecurityException) { + Logger.e(LOG_TAG_BUG_REPORT, "security exception while copying file", e) + } catch (e: Exception) { + Logger.w(LOG_TAG_BUG_REPORT, "err while copying file", e) } } } diff --git a/app/src/full/java/com/celzero/bravedns/scheduler/EnhancedBugReport.kt b/app/src/full/java/com/celzero/bravedns/scheduler/EnhancedBugReport.kt index 678e5312d..b722dcb93 100644 --- a/app/src/full/java/com/celzero/bravedns/scheduler/EnhancedBugReport.kt +++ b/app/src/full/java/com/celzero/bravedns/scheduler/EnhancedBugReport.kt @@ -15,11 +15,13 @@ */ package com.celzero.bravedns.scheduler -import Logger import Logger.LOG_TAG_BUG_REPORT import android.content.Context import android.os.Build +import android.util.Log import androidx.annotation.RequiresApi +import com.celzero.bravedns.util.Constants +import com.celzero.bravedns.util.Utilities import java.io.File import java.io.FileInputStream import java.io.FileNotFoundException @@ -31,12 +33,12 @@ import java.util.zip.ZipOutputStream object EnhancedBugReport { - private const val TOMBSTONE_DIR_NAME = "tombstone" + const val TOMBSTONE_DIR_NAME = "tombstone" private const val TOMBSTONE_FILE_NAME = "tombstone_" private const val MAX_TOMBSTONE_FILES = 5 // maximum files allowed as part of tombstone zip file private const val MAX_FILE_SIZE = 1024 * 1024 // 1MB private const val FILE_EXTENSION = ".txt" - private const val ZIP_FILE_NAME = "rethinkdns.tombstone.zip" + const val TOMBSTONE_ZIP_FILE_NAME = "rethinkdns.tombstone.zip" @RequiresApi(Build.VERSION_CODES.O) fun addLogsToZipFile(context: Context) { @@ -44,47 +46,60 @@ object EnhancedBugReport { // create a new zip file named rethinkdns.tombstone.zip // add the logs to the zip file // close the zip file - val zipFilePath = File(context.filesDir.canonicalPath + File.separator + ZIP_FILE_NAME) - Logger.d(LOG_TAG_BUG_REPORT, "zip file path: $zipFilePath") - val zipOutputStream = ZipOutputStream(FileOutputStream(zipFilePath)) - val folder = getFolderPath(context.filesDir) ?: return - val files = File(folder).listFiles() - Logger.d(LOG_TAG_BUG_REPORT, "files to add to zip: ${files?.size}") - files?.forEach { file -> - val inputStream = FileInputStream(file) - val zipEntry = ZipEntry(file.name) - zipOutputStream.putNextEntry(zipEntry) - inputStream.copyTo(zipOutputStream) - inputStream.close() + val zipFilePath = File(context.filesDir.canonicalPath + File.separator + TOMBSTONE_ZIP_FILE_NAME) + Log.d(LOG_TAG_BUG_REPORT, "zip file path: $zipFilePath") + ZipOutputStream(FileOutputStream(zipFilePath)).use { zipOutputStream -> + val folder = getFolderPath(context.filesDir) ?: return + val files = File(folder).listFiles() ?: return + + Log.d(LOG_TAG_BUG_REPORT, "files to add to zip: ${files.size}") + files.forEach { file -> + try { + FileInputStream(file).use { inputStream -> + Log.v(LOG_TAG_BUG_REPORT, "adding file to zip: ${file.name}, size: ${file.length()}") + val zipEntry = ZipEntry(file.name) + zipOutputStream.putNextEntry(zipEntry) + inputStream.copyTo(zipOutputStream) + } + } catch (e: FileNotFoundException) { + Log.e(LOG_TAG_BUG_REPORT, "file not found: ${file.name}, ${e.message}", e) + } catch (e: Exception) { + Log.e(LOG_TAG_BUG_REPORT, "err adding file to zip: ${file.name}, ${e.message}", e) + } + } } - zipOutputStream.close() + Log.i(LOG_TAG_BUG_REPORT, "zip file created: ${zipFilePath.absolutePath}") } catch (e: FileNotFoundException) { - Logger.e(LOG_TAG_BUG_REPORT, "err adding logs to zip file: ${e.message}", e) + Log.e(LOG_TAG_BUG_REPORT, "err adding logs to zip file: ${e.message}", e) } catch (e: ZipException) { - Logger.e(LOG_TAG_BUG_REPORT, "err adding logs to zip file: ${e.message}", e) + Log.e(LOG_TAG_BUG_REPORT, "err adding logs to zip file: ${e.message}", e) } catch (e: Exception) { - Logger.e(LOG_TAG_BUG_REPORT, "err adding logs to zip file: ${e.message}", e) + Log.e(LOG_TAG_BUG_REPORT, "err adding logs to zip file: ${e.message}", e) + } finally { + } - Logger.i(LOG_TAG_BUG_REPORT, "logs added to zip file") + Log.i(LOG_TAG_BUG_REPORT, "logs added to zip file") } fun writeLogsToFile(context: Context, logs: String) { try { val file = getFileToWrite(context) if (file == null) { - Logger.e(LOG_TAG_BUG_REPORT, "file name is null, cannot write logs to file") + Log.e(LOG_TAG_BUG_REPORT, "file name is null, cannot write logs to file") return } - val l = logs + "\n" // append a new line character + val time = Utilities.convertLongToTime(System.currentTimeMillis(), Constants.TIME_FORMAT_3) + val l = "\n$time: $logs" file.appendText(l, Charset.defaultCharset()) + Log.v(LOG_TAG_BUG_REPORT, "logs written to file: ${file.absolutePath}") } catch (e: Exception) { - Logger.e(LOG_TAG_BUG_REPORT, "err writing logs to file: ${e.message}", e) + Log.e(LOG_TAG_BUG_REPORT, "err writing logs to file: ${e.message}", e) } } private fun getFileToWrite(context: Context): File? { val file = getTombstoneFile(context) - Logger.d(LOG_TAG_BUG_REPORT, "file to write logs: ${file?.name}") + Log.d(LOG_TAG_BUG_REPORT, "file to write logs: ${file?.name}") return file } @@ -92,20 +107,20 @@ object EnhancedBugReport { try { val folderPath = getFolderPath(context.filesDir) if (folderPath == null) { - Logger.e(LOG_TAG_BUG_REPORT, "folder path is null, cannot get tombstone file") + Log.e(LOG_TAG_BUG_REPORT, "folder path is null, cannot get tombstone file") return null } val folder = File(folderPath) val files = folder.listFiles() if (files.isNullOrEmpty()) { - Logger.d(LOG_TAG_BUG_REPORT, "no files found in the tombstone folder") + Log.d(LOG_TAG_BUG_REPORT, "no files found in the tombstone folder") return createTombstoneFile(folderPath) } else { - Logger.d(LOG_TAG_BUG_REPORT, "files found in the tombstone folder") + Log.d(LOG_TAG_BUG_REPORT, "files found in the tombstone folder") return getLatestFile(files) ?: createTombstoneFile(folderPath) } } catch (e: Exception) { - Logger.e(LOG_TAG_BUG_REPORT, "err getting tombstone file: ${e.message}", e) + Log.e(LOG_TAG_BUG_REPORT, "err getting tombstone file: ${e.message}", e) return null } } @@ -114,52 +129,25 @@ object EnhancedBugReport { if (files.isEmpty()) { return null } - files.sortByDescending { it.name } - var latestFile = files[0] - var latestTimestamp = latestFile.lastModified() - Logger.vv(LOG_TAG_BUG_REPORT, "latest timestamp: $latestTimestamp, ${latestFile.name}") - for (file in files) { - Logger.vv(LOG_TAG_BUG_REPORT, "file timestamp: ${file.lastModified()}, ${file.name}") - if (file.lastModified() > latestTimestamp) { - latestFile = file - latestTimestamp = file.lastModified() - Logger.vv(LOG_TAG_BUG_REPORT, "updated timestamp: $latestTimestamp, ${latestFile.name}") - } - } - - return latestFile - } - private fun findOldestFile(files: Array): File? { - if (files.isEmpty()) { - return null - } - files.sortBy { it.name } - var oldestFile = files[0] - var oldestTimestamp = oldestFile.lastModified() - Logger.vv(LOG_TAG_BUG_REPORT, "oldest timestamp: $oldestTimestamp, ${oldestFile.name}") - for (file in files) { - Logger.vv(LOG_TAG_BUG_REPORT, "file timestamp: ${file.lastModified()}, ${file.name}") - if (file.lastModified() < oldestTimestamp) { - oldestFile = file - oldestTimestamp = file.lastModified() - Logger.vv(LOG_TAG_BUG_REPORT, "updated timestamp: $oldestTimestamp, ${oldestFile.name}") - } + return files.maxByOrNull { it.lastModified() }?.also { + Log.v(LOG_TAG_BUG_REPORT, "latest file: ${it.name}, timestamp: ${it.lastModified()}") + } ?: run { + Log.w(LOG_TAG_BUG_REPORT, "no files found to determine latest file") + null } - - return oldestFile } fun getTombstoneZipFile(context: Context): File? { try { - val zipFile = File(context.filesDir.canonicalPath + File.separator + ZIP_FILE_NAME) + val zipFile = File(context.filesDir.canonicalPath + File.separator + TOMBSTONE_ZIP_FILE_NAME) if (!zipFile.exists()) { - Logger.w(LOG_TAG_BUG_REPORT, "zip file is null, cannot add logs to zip file") + Log.w(LOG_TAG_BUG_REPORT, "zip file is null, cannot add logs to zip file") return null } return zipFile } catch (e: Exception) { - Logger.e(LOG_TAG_BUG_REPORT, "err getting tombstone zip file: ${e.message}", e) + Log.e(LOG_TAG_BUG_REPORT, "err getting tombstone zip file: ${e.message}", e) } return null } @@ -167,25 +155,34 @@ object EnhancedBugReport { private fun getLatestFile(files: Array): File? { try { if (files.isEmpty()) { - Logger.w(LOG_TAG_BUG_REPORT, "no files found in the tombstone folder") + Log.w(LOG_TAG_BUG_REPORT, "no files found in the tombstone folder") return null } + + // if the file count is more than MAX_TOMBSTONE_FILES, delete the oldest file + val totalSize = files.sumOf { it.length() } + val maxDirSize = MAX_FILE_SIZE * MAX_TOMBSTONE_FILES + if (totalSize > maxDirSize) { + files.sortedByDescending { it.lastModified() } + .drop(MAX_TOMBSTONE_FILES) + .forEach { file -> + if (!file.delete()) { + Log.w(LOG_TAG_BUG_REPORT, "failed to delete file: ${file.name}") + } else { + Log.i(LOG_TAG_BUG_REPORT, "deleted old file: ${file.name}") + } + } + } val latestFile = findLatestFile(files) ?: return null if (latestFile.length() > MAX_FILE_SIZE) { - Logger.d(LOG_TAG_BUG_REPORT, "file size is more than 1MB, ${latestFile.name}") + Log.d(LOG_TAG_BUG_REPORT, "file size is more than 1MB, ${latestFile.name}") // create a new file val parent = latestFile.parent ?: return null return createTombstoneFile(parent) } - // if the file count is more than MAX_TOMBSTONE_FILES, delete the oldest file - if (files.size > MAX_TOMBSTONE_FILES) { - val fileToDelete = findOldestFile(files) ?: return null - Logger.i(LOG_TAG_BUG_REPORT, "deleted the oldest file ${fileToDelete.name}, file count: ${files.size}") - fileToDelete.delete() - } return latestFile } catch (e: Exception) { - Logger.e(LOG_TAG_BUG_REPORT, "err getting latest file: ${e.message}", e) + Log.e(LOG_TAG_BUG_REPORT, "err getting latest file: ${e.message}", e) } return null } @@ -196,11 +193,15 @@ object EnhancedBugReport { val ts = System.currentTimeMillis() val file = File(folderPath + File.separator + TOMBSTONE_FILE_NAME + ts + FILE_EXTENSION) - file.createNewFile() - Logger.d(LOG_TAG_BUG_REPORT, "created tombstone file: ${file.name}") + val created = file.createNewFile() + if (!created) { + Log.e(LOG_TAG_BUG_REPORT, "failed to create tombstone file: ${file.name}, $folderPath") + return null + } + Log.i(LOG_TAG_BUG_REPORT, "tombstone file created: ${file.absolutePath}") return file } catch (e: Exception) { - Logger.e(LOG_TAG_BUG_REPORT, "err creating tombstone file: ${e.message}", e) + Log.e(LOG_TAG_BUG_REPORT, "err creating tombstone file: ${e.message}", e) } return null } @@ -212,14 +213,27 @@ object EnhancedBugReport { val path = file.canonicalPath + File.separator + TOMBSTONE_DIR_NAME val folder = File(path) if (folder.exists()) { - Logger.vv(LOG_TAG_BUG_REPORT, "folder exists: $path") + Log.v(LOG_TAG_BUG_REPORT, "folder exists: $path") } else { - folder.mkdir() - Logger.vv(LOG_TAG_BUG_REPORT, "folder created: $path") + val created = folder.mkdir() + if (!created) { + Log.e(LOG_TAG_BUG_REPORT, "failed to create folder: $path") + return null + } + Log.v(LOG_TAG_BUG_REPORT, "folder created: $path") } + if (!folder.isDirectory) { + Log.e(LOG_TAG_BUG_REPORT, "path is not a directory: $path") + return null + } + if (!folder.canWrite()) { + Log.e(LOG_TAG_BUG_REPORT, "folder is not writable: $path") + return null + } + return path } catch (e: Exception) { - Logger.e(LOG_TAG_BUG_REPORT, "err getting folder path: ${e.message}", e) + Log.e(LOG_TAG_BUG_REPORT, "err getting folder path: ${e.message}", e) } return null } diff --git a/app/src/full/java/com/celzero/bravedns/scheduler/LogExportWorker.kt b/app/src/full/java/com/celzero/bravedns/scheduler/LogExportWorker.kt new file mode 100644 index 000000000..8fe7065ca --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/scheduler/LogExportWorker.kt @@ -0,0 +1,93 @@ +/* + * Copyright 2024 RethinkDNS and its authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.celzero.bravedns.scheduler + +import Logger +import Logger.LOG_TAG_BUG_REPORT +import android.content.Context +import android.database.Cursor +import androidx.sqlite.db.SimpleSQLiteQuery +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.celzero.bravedns.database.ConsoleLogDAO +import java.io.File +import java.io.FileOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.io.BufferedOutputStream + +class LogExportWorker(context: Context, workerParams: WorkerParameters) : + CoroutineWorker(context, workerParams), KoinComponent { + + private val consoleLogDao by inject() + + companion object { + private const val QUERY = "SELECT * FROM ConsoleLog order by id" + } + + override suspend fun doWork(): Result { + return try { + val filePath = inputData.getString("filePath") ?: return Result.failure() + Logger.i(LOG_TAG_BUG_REPORT, "Exporting logs to $filePath") + exportLogsToCsvStream(filePath) + Result.success() + } catch (e: Exception) { + Result.failure() + } + } + + private fun exportLogsToCsvStream(filePath: String): Boolean { + var cursor: Cursor? = null + try { + val query = SimpleSQLiteQuery(QUERY) + cursor = consoleLogDao.getLogsCursor(query) + + val file = File(filePath) + if (file.exists()) { + Logger.v(LOG_TAG_BUG_REPORT, "Deleting existing zip file, ${file.absolutePath}") + file.delete() + } + + val stringBuilder = StringBuilder() + cursor.let { + if (it.moveToFirst()) { + do { + val timestamp = it.getLong(it.getColumnIndexOrThrow("timestamp")) + val message = it.getString(it.getColumnIndexOrThrow("message")) + stringBuilder.append("$timestamp,$message\n") + } while (it.moveToNext()) + } + } + + ZipOutputStream(BufferedOutputStream(FileOutputStream(filePath))).use { zos -> + val zipEntry = ZipEntry("log_${System.currentTimeMillis()}.txt") + zos.putNextEntry(zipEntry) + zos.write(stringBuilder.toString().toByteArray()) + zos.closeEntry() + } + + Logger.i(LOG_TAG_BUG_REPORT, "Logs exported to ${file.absolutePath}") + return true + } catch (e: Exception) { + Logger.e(LOG_TAG_BUG_REPORT, "Error exporting logs", e) + } finally { + cursor?.close() + } + return false + } +} diff --git a/app/src/full/java/com/celzero/bravedns/scheduler/PaymentWorker.kt b/app/src/full/java/com/celzero/bravedns/scheduler/PaymentWorker.kt index bfcf116b7..ef106eff5 100644 --- a/app/src/full/java/com/celzero/bravedns/scheduler/PaymentWorker.kt +++ b/app/src/full/java/com/celzero/bravedns/scheduler/PaymentWorker.kt @@ -23,9 +23,11 @@ import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import com.celzero.bravedns.customdownloader.ITcpProxy import com.celzero.bravedns.customdownloader.RetrofitManager +import com.celzero.bravedns.service.PersistentState import com.celzero.bravedns.service.TcpProxyHelper import org.json.JSONObject import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import retrofit2.converter.gson.GsonConverterFactory import java.util.concurrent.TimeUnit @@ -34,11 +36,14 @@ class PaymentWorker(val context: Context, workerParameters: WorkerParameters) : companion object { val MAX_RETRY_TIMEOUT = TimeUnit.MINUTES.toMillis(40) + private const val MAX_RETRY_COUNT = 3 private const val JSON_PAYMENT_STATUS = "payment_status" private const val JSON_STATUS = "status" } + private val persistentState by inject() + override suspend fun doWork(): Result { val startTime = inputData.getLong("workerStartTime", 0) @@ -74,7 +79,7 @@ class PaymentWorker(val context: Context, workerParameters: WorkerParameters) : var paymentStatus = TcpProxyHelper.PaymentStatus.INITIATED try { val retrofit = - RetrofitManager.getTcpProxyBaseBuilder(retryCount) + RetrofitManager.getTcpProxyBaseBuilder(persistentState.routeRethinkInRethink) .addConverterFactory(GsonConverterFactory.create()) .build() val retrofitInterface = retrofit.create(ITcpProxy::class.java) @@ -126,6 +131,6 @@ class PaymentWorker(val context: Context, workerParameters: WorkerParameters) : } private fun isRetryRequired(retryCount: Int): Boolean { - return retryCount < RetrofitManager.Companion.OkHttpDnsType.entries.size - 1 + return retryCount < MAX_RETRY_COUNT } } diff --git a/app/src/full/java/com/celzero/bravedns/scheduler/PurgeConsoleLogs.kt b/app/src/full/java/com/celzero/bravedns/scheduler/PurgeConsoleLogs.kt new file mode 100644 index 000000000..44f5c47a3 --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/scheduler/PurgeConsoleLogs.kt @@ -0,0 +1,43 @@ +package com.celzero.bravedns.scheduler + +import Logger.LOG_BATCH_LOGGER +import android.content.Context +import androidx.paging.LOG_TAG +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.celzero.bravedns.database.ConsoleLogRepository +import com.celzero.bravedns.service.PersistentState +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.util.concurrent.TimeUnit + +class PurgeConsoleLogs(val context: Context, workerParameters: WorkerParameters) : + CoroutineWorker(context, workerParameters), KoinComponent { + + private val consoleLogRepository by inject() + + companion object { + const val MAX_TIME: Long = 3 // max time in hours to keep the console logs + } + override suspend fun doWork(): Result { + // delete logs which are older than MAX_TIME hrs + val threshold = TimeUnit.HOURS.toMillis(MAX_TIME) + val currTime = System.currentTimeMillis() + val time = currTime - threshold + + consoleLogRepository.deleteOldLogs(time) + val startTime = consoleLogRepository.consoleLogStartTimestamp + val lapsedTime = currTime - startTime + // stop the console log if it exceeds max time and set the log level to ERROR + // this is to avoid the console log from growing indefinitely + // no need to reset the start timestamp/logger level if it is already set to ERROR or above + if (lapsedTime > TimeUnit.MINUTES.toMillis(MAX_TIME) && Logger.uiLogLevel < Logger.LoggerLevel.ERROR.id) { + consoleLogRepository.consoleLogStartTimestamp = 0 + Logger.uiLogLevel = Logger.LoggerLevel.ERROR.id + Logger.i(LOG_BATCH_LOGGER, "console log purged, disabled as it exceeded max time of $MAX_TIME hrs") + } + Logger.v(LOG_BATCH_LOGGER, "purged console logs older than $MAX_TIME hrs, current time: $currTime, start time: $startTime, lapsed time: $lapsedTime") + return Result.success() + } + +} diff --git a/app/src/full/java/com/celzero/bravedns/scheduler/RpnProxiesUpdateWorker.kt b/app/src/full/java/com/celzero/bravedns/scheduler/RpnProxiesUpdateWorker.kt new file mode 100644 index 000000000..4a313f1f3 --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/scheduler/RpnProxiesUpdateWorker.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2025 RethinkDNS and its authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.celzero.bravedns.scheduler + +import Logger.LOG_TAG_SCHEDULER +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.celzero.bravedns.rpnproxy.RpnProxyManager + +class RpnProxiesUpdateWorker(context: Context, params: WorkerParameters) : + CoroutineWorker(context, params) { + + companion object { + private const val TAG = "RpnUpdateWorker(rpn)" + } + + override suspend fun doWork(): Result { + // perform the update only if RPN is active + if (!RpnProxyManager.isRpnActive()) { + Logger.i(LOG_TAG_SCHEDULER, "$TAG RPN is not active, skipping update") + return Result.success() + } + var type = -1 // default value + try { + val now = System.currentTimeMillis() + type = inputData.getInt("type", -1) + if (type == -1) { + Logger.e(LOG_TAG_SCHEDULER, "$TAG invalid proxy type received") + return Result.failure() + } + val rpnType = RpnProxyManager.RpnType.fromId(type) + val res = updateExpiryStatus(rpnType) + Logger.i(LOG_TAG_SCHEDULER, "$TAG update worker completed for $type at $now, res? $res") + return if (res) Result.success() else retryIfNeeded() + } catch (e: Exception) { + Logger.e(LOG_TAG_SCHEDULER, "$TAG err on update, type($type), ${e.message}") + return retryIfNeeded() + } + } + + private fun retryIfNeeded(): Result { + val maxRetryCount = 3 + if (runAttemptCount >= maxRetryCount) { + Logger.w(LOG_TAG_SCHEDULER, "max retry count reached for $TAG, count: $runAttemptCount") + return Result.failure() + } + Logger.i(LOG_TAG_SCHEDULER, "retrying $TAG, attempt: $runAttemptCount") + return Result.retry() + } + + private suspend fun updateExpiryStatus(type: RpnProxyManager.RpnType): Boolean { + Logger.d(LOG_TAG_SCHEDULER, "$type has expired and is being processed") + var res = RpnProxyManager.refreshRpnCreds(type) + Logger.i(LOG_TAG_SCHEDULER, "refresh cred for $type, success? $res") + + if (!res) { + Logger.e(LOG_TAG_SCHEDULER, "$TAG refresh-cred failed for $type, trying fresh register") + // register is a fresh registration with null creds, happens when refresh fails + res = RpnProxyManager.registerNewProxy(type) + } + Logger.d(LOG_TAG_SCHEDULER, "new creds updated for $type, success? $res") + return res + } +} diff --git a/app/src/full/java/com/celzero/bravedns/scheduler/WorkScheduler.kt b/app/src/full/java/com/celzero/bravedns/scheduler/WorkScheduler.kt index 624682d3a..d850d4ccb 100644 --- a/app/src/full/java/com/celzero/bravedns/scheduler/WorkScheduler.kt +++ b/app/src/full/java/com/celzero/bravedns/scheduler/WorkScheduler.kt @@ -20,36 +20,45 @@ import Logger import Logger.LOG_TAG_SCHEDULER import android.content.Context import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.Data import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.PeriodicWorkRequest import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.WorkRequest +import com.celzero.bravedns.rpnproxy.RpnProxyManager import com.celzero.bravedns.util.Utilities import com.google.common.util.concurrent.ListenableFuture +import java.util.UUID import java.util.concurrent.ExecutionException import java.util.concurrent.TimeUnit class WorkScheduler(val context: Context) { + private val rpnProxiesWorkMap = mutableMapOf() companion object { const val APP_EXIT_INFO_ONE_TIME_JOB_TAG = "OnDemandCollectAppExitInfoJob" const val APP_EXIT_INFO_JOB_TAG = "ScheduledCollectAppExitInfoJob" const val PURGE_CONNECTION_LOGS_JOB_TAG = "ScheduledPurgeConnectionLogsJob" + const val PURGE_CONSOLE_LOGS_JOB_TAG = "ScheduledPurgeConsoleLogsJob" const val BLOCKLIST_UPDATE_CHECK_JOB_TAG = "ScheduledBlocklistUpdateCheckJob" const val DATA_USAGE_JOB_TAG = "ScheduledDataUsageJob" + const val CONSOLE_LOG_SAVE_JOB_TAG = "ConsoleLogSaveJob" const val APP_EXIT_INFO_JOB_TIME_INTERVAL_DAYS: Long = 7 const val PURGE_LOGS_TIME_INTERVAL_HOURS: Long = 4 + const val PURGE_CONSOLE_LOGS_TIME_INTERVAL_HOURS: Long = 3 const val BLOCKLIST_UPDATE_CHECK_INTERVAL_DAYS: Long = 3 const val DATA_USAGE_TIME_INTERVAL_MINS: Long = 20 fun isWorkRunning(context: Context, tag: String): Boolean { val instance = WorkManager.getInstance(context) val statuses: ListenableFuture> = instance.getWorkInfosByTag(tag) - Logger.d(LOG_TAG_SCHEDULER, "Job $tag already running check") + Logger.i(LOG_TAG_SCHEDULER, "Job $tag already running check") return try { var running = false val workInfos = statuses.get() @@ -75,7 +84,7 @@ class WorkScheduler(val context: Context) { fun isWorkScheduled(context: Context, tag: String): Boolean { val instance = WorkManager.getInstance(context) val statuses: ListenableFuture> = instance.getWorkInfosByTag(tag) - Logger.d(LOG_TAG_SCHEDULER, "Job $tag already scheduled check") + Logger.i(LOG_TAG_SCHEDULER, "Job $tag already scheduled check") return try { var running = false val workInfos = statuses.get() @@ -85,7 +94,7 @@ class WorkScheduler(val context: Context) { for (workStatus in workInfos) { running = workStatus.state == WorkInfo.State.RUNNING || - workStatus.state == WorkInfo.State.ENQUEUED + workStatus.state == WorkInfo.State.ENQUEUED } Logger.i(LOG_TAG_SCHEDULER, "Job $tag already scheduled? $running") running @@ -104,13 +113,13 @@ class WorkScheduler(val context: Context) { // app exit info is supported from R+ if (!Utilities.isAtleastR()) return - Logger.d(LOG_TAG_SCHEDULER, "App exit info job scheduled") + Logger.i(LOG_TAG_SCHEDULER, "App exit info job scheduled") val bugReportCollector = PeriodicWorkRequest.Builder( - BugReportCollector::class.java, - APP_EXIT_INFO_JOB_TIME_INTERVAL_DAYS, - TimeUnit.DAYS - ) + BugReportCollector::class.java, + APP_EXIT_INFO_JOB_TIME_INTERVAL_DAYS, + TimeUnit.DAYS + ) .addTag(APP_EXIT_INFO_JOB_TAG) .build() WorkManager.getInstance(context.applicationContext) @@ -124,14 +133,14 @@ class WorkScheduler(val context: Context) { fun schedulePurgeConnectionsLog() { val purgeLogs = PeriodicWorkRequest.Builder( - PurgeConnectionLogs::class.java, - PURGE_LOGS_TIME_INTERVAL_HOURS, - TimeUnit.HOURS - ) + PurgeConnectionLogs::class.java, + PURGE_LOGS_TIME_INTERVAL_HOURS, + TimeUnit.HOURS + ) .addTag(PURGE_CONNECTION_LOGS_JOB_TAG) .build() - Logger.d(LOG_TAG_SCHEDULER, "purge connection logs job scheduled") + Logger.i(LOG_TAG_SCHEDULER, "purge connection logs job scheduled") WorkManager.getInstance(context.applicationContext) .enqueueUniquePeriodicWork( PURGE_CONNECTION_LOGS_JOB_TAG, @@ -140,6 +149,25 @@ class WorkScheduler(val context: Context) { ) } + fun schedulePurgeConsoleLogs() { + val purgeLogs = + PeriodicWorkRequest.Builder( + PurgeConsoleLogs::class.java, + PURGE_CONSOLE_LOGS_TIME_INTERVAL_HOURS, + TimeUnit.HOURS + ) + .addTag(PURGE_CONSOLE_LOGS_JOB_TAG) + .build() + + Logger.i(LOG_TAG_SCHEDULER, "purge console logs job scheduled") + WorkManager.getInstance(context.applicationContext) + .enqueueUniquePeriodicWork( + PURGE_CONSOLE_LOGS_JOB_TAG, + ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, + purgeLogs + ) + } + // Schedule AppExitInfo on demand fun scheduleOneTimeWorkForAppExitInfo() { val bugReportCollector = @@ -167,10 +195,10 @@ class WorkScheduler(val context: Context) { Logger.i(LOG_TAG_SCHEDULER, "Scheduled blocklist update check") val blocklistUpdateCheck = PeriodicWorkRequest.Builder( - BlocklistUpdateCheckJob::class.java, - BLOCKLIST_UPDATE_CHECK_INTERVAL_DAYS, - TimeUnit.DAYS - ) + BlocklistUpdateCheckJob::class.java, + BLOCKLIST_UPDATE_CHECK_INTERVAL_DAYS, + TimeUnit.DAYS + ) .addTag(BLOCKLIST_UPDATE_CHECK_JOB_TAG) .build() WorkManager.getInstance(context.applicationContext) @@ -185,10 +213,10 @@ class WorkScheduler(val context: Context) { Logger.i(LOG_TAG_SCHEDULER, "Data usage job scheduled") val workRequest = PeriodicWorkRequest.Builder( - DataUsageUpdater::class.java, - DATA_USAGE_TIME_INTERVAL_MINS, - TimeUnit.MINUTES // Set the repeat interval for every 15 minutes - ) + DataUsageUpdater::class.java, + DATA_USAGE_TIME_INTERVAL_MINS, + TimeUnit.MINUTES // Set the repeat interval for every 15 minutes + ) .addTag(DATA_USAGE_JOB_TAG) .build() @@ -199,4 +227,62 @@ class WorkScheduler(val context: Context) { workRequest ) } + + fun scheduleConsoleLogSaveJob(filePath: String) { + Logger.i(LOG_TAG_SCHEDULER, "Console log save job scheduled") + val inputData = Data.Builder().putString("filePath", filePath).build() + val workRequest = + OneTimeWorkRequestBuilder() + .addTag(CONSOLE_LOG_SAVE_JOB_TAG) + .setInputData(inputData) + .build() + + WorkManager.getInstance(context.applicationContext) + .enqueueUniqueWork( + CONSOLE_LOG_SAVE_JOB_TAG, + ExistingWorkPolicy.REPLACE, + workRequest + ) + } + + fun scheduleRpnProxiesUpdate(type: RpnProxyManager.RpnType, expiryTimeMs: Long) { + Logger.v(LOG_TAG_SCHEDULER, "init; rpn proxies update scheduled for ${type.name}") + val now = System.currentTimeMillis() + val inputDataKey = "type" + + // cancel the existing work if any + rpnProxiesWorkMap[type]?.let { + WorkManager.getInstance(context.applicationContext).cancelWorkById(it) + rpnProxiesWorkMap.remove(type) + } + var delay = expiryTimeMs - now + if (delay <= 0) { + Logger.i(LOG_TAG_SCHEDULER, "rpn proxies update expired for ${type.name}") + // perform the update immediately as the expiry time is already passed + delay = 0 + } + + val inputData = Data.Builder().putInt(inputDataKey, type.id).build() + // schedule the work when the internet is available + val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build() + + val workRequest = + OneTimeWorkRequestBuilder() + .setInitialDelay(delay, TimeUnit.MILLISECONDS) + .setInputData(inputData) + .setConstraints(constraints) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + WorkRequest.MIN_BACKOFF_MILLIS, + TimeUnit.MILLISECONDS + ) + .build() + + WorkManager.getInstance(context.applicationContext) + .enqueue(workRequest) + + rpnProxiesWorkMap[type] = workRequest.id + Logger.i(LOG_TAG_SCHEDULER, "rpn proxies update scheduled for ${type.name}") + Logger.v(LOG_TAG_SCHEDULER, "rpn proxies update map: $rpnProxiesWorkMap") + } } diff --git a/app/src/full/java/com/celzero/bravedns/service/ProxyManager.kt b/app/src/full/java/com/celzero/bravedns/service/ProxyManager.kt index 564bb687b..70b1daf51 100644 --- a/app/src/full/java/com/celzero/bravedns/service/ProxyManager.kt +++ b/app/src/full/java/com/celzero/bravedns/service/ProxyManager.kt @@ -17,7 +17,7 @@ package com.celzero.bravedns.service import Logger import Logger.LOG_TAG_PROXY -import backend.Backend +import com.celzero.firestack.backend.Backend import com.celzero.bravedns.database.AppInfo import com.celzero.bravedns.database.ProxyAppMappingRepository import com.celzero.bravedns.database.ProxyApplicationMapping @@ -195,6 +195,28 @@ object ProxyManager : KoinComponent { m.forEach { addNewApp(it) } } + suspend fun addApp(appInfo: AppInfo?) { + addNewApp(appInfo) + } + + suspend fun updateApp(uid: Int, packageName: String) { + if (pamSet.any { it.uid == uid && it.packageName == packageName }) { + Logger.i(LOG_TAG_PROXY, "App already exists in proxy mapping: $packageName") + return + } + // update the uid for the app in the database and the cache + // assuming pamSet will always be synced with the database + val m = pamSet.filter { it.packageName == packageName }.toSet() + if (m.isNotEmpty()) { + val n = m.map { ProxyAppMapTuple(uid, packageName, it.proxyId) } + pamSet.removeAll(m) + pamSet.addAll(n) + db.updateUidForApp(uid, packageName) + } else { + Logger.e(LOG_TAG_PROXY, "updateApp: map not found for uid $uid") + } + } + suspend fun purgeDupsBeforeRefresh() { val visited = mutableSetOf() val dups = mutableSetOf() @@ -223,6 +245,10 @@ object ProxyManager : KoinComponent { Logger.e(LOG_TAG_PROXY, "AppInfo is null, cannot add to proxy") return } + if (pamSet.any { it.uid == appInfo.uid && it.packageName == appInfo.packageName }) { + Logger.i(LOG_TAG_PROXY, "App already exists in proxy mapping: ${appInfo.appName}") + return + } val pam = ProxyApplicationMapping( appInfo.uid, @@ -238,30 +264,36 @@ object ProxyManager : KoinComponent { Logger.i(LOG_TAG_PROXY, "Adding app for mapping: ${pam.appName}, ${pam.uid}") } - private fun deleteFromCache(pam: ProxyApplicationMapping) { + private fun deleteFromCache(uid: Int, packageName: String) { pamSet.forEach { - if (it.uid == pam.uid && it.packageName == pam.packageName) { + if (it.uid == uid && it.packageName == packageName) { pamSet.remove(it) } } } - suspend fun deleteAppMappingsByUid(uid: Int) { - val m = pamSet.filter { it.uid == uid } - m.forEach { deleteApp(it.uid, it.packageName) } + suspend fun deleteApp(uid: Int, packageName: String) { + deleteFromCache(uid, packageName) + db.deleteApp(uid, packageName) + Logger.i(LOG_TAG_PROXY, "deleting app for mapping: $uid, $packageName") } - private suspend fun deleteApp(uid: Int, packageName: String) { - val pam = ProxyApplicationMapping(uid, packageName, "", "", false, "") - deleteFromCache(pam) - db.deleteApp(pam) - Logger.d(LOG_TAG_PROXY, "Deleting app for mapping: ${pam.appName}, ${pam.uid}") + suspend fun deleteAppIfNeeded(uid: Int, packageName: String) { + val fm = FirewallManager.getAppInfoByPackage(packageName) + // if there is no app info for the package, then delete the app from the mapping + if (fm == null) { + deleteApp(uid, packageName) + return + } else { + // the app can be tombstoned, so do not delete the app from the mapping + Logger.i(LOG_TAG_PROXY, "deleteAppIfNeeded: app($uid, $packageName) is available in firewall manager, not deleting, tombstone: ${fm.tombstoneTs}") + } } suspend fun clear() { pamSet.clear() db.deleteAll() - Logger.d(LOG_TAG_PROXY, "Deleting all apps for mapping") + Logger.d(LOG_TAG_PROXY, "deleting all apps for mapping") } fun isAnyAppSelected(proxyId: String): Boolean { @@ -281,21 +313,33 @@ object ProxyManager : KoinComponent { return pamSet.count { it.proxyId == proxyId } } - suspend fun removeWgProxies() { - // remove all the wg proxies from the app config mappings, during restore process - val noProxy = "" - val m = pamSet.filter { it.proxyId.startsWith(ID_WG_BASE) }.toSet() - val n = m.map { ProxyAppMapTuple(it.uid, it.packageName, noProxy) } - pamSet.removeAll(m) - pamSet.addAll(n) - db.removeAllWgProxies() - } - fun isIpnProxy(ipnProxyId: String): Boolean { if (ipnProxyId.isEmpty()) return false - // if id is not Ipn.Base, Ipn.Block, Ipn.Exit then it is proxied + // check if the proxy id is not the base, block, exit, auto or ingress + // all these are special cases and should not be considered as proxied traffic return ipnProxyId != Backend.Base && ipnProxyId != Backend.Block && - ipnProxyId != Backend.Exit + ipnProxyId != Backend.Exit && + ipnProxyId != Backend.Auto && + ipnProxyId != Backend.Ingress && + ipnProxyId != Backend.Plus && + !ipnProxyId.endsWith(Backend.RPN) + } + + fun isRpnProxy(ipnProxyId: String): Boolean { + if (ipnProxyId.isEmpty()) return false + // check if the proxy id is not the base, block, exit, auto or ingress + // all these are special cases and should not be considered as proxied traffic + return ipnProxyId.endsWith(Backend.RPN) || ipnProxyId == Backend.Auto + } + + fun stats(): String { + val sb = StringBuilder() + sb.append(" apps: ${pamSet.size}\n") + sb.append(" wg: ${WireguardManager.getNumberOfMappings()}\n") + sb.append(" active wgs: ${WireguardManager.getActiveWgCount()}\n") + sb.append(" isOneWgActive: ${WireguardManager.oneWireGuardEnabled()}\n") + + return sb.toString() } } diff --git a/app/src/full/java/com/celzero/bravedns/service/TcpProxyHelper.kt b/app/src/full/java/com/celzero/bravedns/service/TcpProxyHelper.kt index 05ff43b8f..1ff1a941e 100644 --- a/app/src/full/java/com/celzero/bravedns/service/TcpProxyHelper.kt +++ b/app/src/full/java/com/celzero/bravedns/service/TcpProxyHelper.kt @@ -10,13 +10,15 @@ import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.WorkRequest -import backend.Backend +import com.celzero.firestack.backend.Backend import com.celzero.bravedns.customdownloader.ITcpProxy import com.celzero.bravedns.customdownloader.RetrofitManager import com.celzero.bravedns.data.AppConfig import com.celzero.bravedns.database.TcpProxyEndpoint import com.celzero.bravedns.database.TcpProxyRepository import com.celzero.bravedns.scheduler.PaymentWorker +import com.celzero.bravedns.util.Utilities.togs +import com.celzero.firestack.backend.IpTree import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -36,9 +38,10 @@ object TcpProxyHelper : KoinComponent { private val tcpProxies = mutableSetOf() - private var cfIpTrie: backend.IpTree = Backend.newIpTree() + private var cfIpTrie: IpTree = Backend.newIpTree() private const val DEFAULT_ID = 0 + private const val MAX_RETRY_COUNT = 3 const val PAYMENT_WORKER_TAG = "payment_worker_tag" private const val JSON_MIN_VERSION_CODE = "minvcode" @@ -104,7 +107,7 @@ object TcpProxyHelper : KoinComponent { private fun loadTrie() { cfIpTrie = Backend.newIpTree() - cfIpAddresses.forEach { cfIpTrie.set(it, "") } + cfIpAddresses.forEach { cfIpTrie.set(it.togs(), "".togs()) } Logger.d(LOG_TAG_PROXY, "loadTrie: loading trie for cloudflare ips") } @@ -112,7 +115,7 @@ object TcpProxyHelper : KoinComponent { // do not check for cloudflare ips for now // return false return try { - cfIpTrie.hasAny(ip) + cfIpTrie.hasAny(ip.togs()) } catch (e: Exception) { Logger.w(LOG_TAG_PROXY, "isCloudflareIp: exception while checking ip: $ip") false @@ -130,7 +133,7 @@ object TcpProxyHelper : KoinComponent { var works = false try { val retrofit = - RetrofitManager.getTcpProxyBaseBuilder(retryCount) + RetrofitManager.getTcpProxyBaseBuilder() .addConverterFactory(GsonConverterFactory.create()) .build() val retrofitInterface = retrofit.create(ITcpProxy::class.java) @@ -175,7 +178,7 @@ object TcpProxyHelper : KoinComponent { } private fun isRetryRequired(retryCount: Int): Boolean { - return retryCount < RetrofitManager.Companion.OkHttpDnsType.entries.size - 1 + return retryCount < MAX_RETRY_COUNT } suspend fun isPaymentInitiated(): Boolean { @@ -202,7 +205,7 @@ object TcpProxyHelper : KoinComponent { Logger.w(LOG_TAG_PROXY, "getTcpProxyPaymentStatus: tcpProxy not found") return PaymentStatus.NOT_PAID } - return PaymentStatus.values().find { it.value == tcpProxy.paymentStatus } + return PaymentStatus.entries.find { it.value == tcpProxy.paymentStatus } ?: PaymentStatus.NOT_PAID } diff --git a/app/src/full/java/com/celzero/bravedns/service/WireguardManager.kt b/app/src/full/java/com/celzero/bravedns/service/WireguardManager.kt index 442a080b1..f11b7941c 100644 --- a/app/src/full/java/com/celzero/bravedns/service/WireguardManager.kt +++ b/app/src/full/java/com/celzero/bravedns/service/WireguardManager.kt @@ -18,34 +18,30 @@ package com.celzero.bravedns.service import Logger import Logger.LOG_TAG_PROXY import android.content.Context -import backend.Backend -import backend.WgKey -import com.celzero.bravedns.customdownloader.IWireguardWarp -import com.celzero.bravedns.customdownloader.RetrofitManager +import android.text.format.DateUtils +import com.celzero.firestack.backend.Backend +import com.celzero.bravedns.backup.BackupHelper.Companion.TEMP_WG_DIR import com.celzero.bravedns.data.AppConfig import com.celzero.bravedns.database.WgConfigFiles import com.celzero.bravedns.database.WgConfigFilesImmutable import com.celzero.bravedns.database.WgConfigFilesRepository +import com.celzero.bravedns.rpnproxy.RpnProxyManager.WARP_ID +import com.celzero.bravedns.rpnproxy.RpnProxyManager.WARP_NAME +import com.celzero.bravedns.service.ProxyManager.ID_WG_BASE +import com.celzero.bravedns.util.Constants.Companion.UID_EVERYBODY import com.celzero.bravedns.util.Constants.Companion.WIREGUARD_FOLDER_NAME -import com.celzero.bravedns.wireguard.BadConfigException +import com.celzero.bravedns.util.Utilities import com.celzero.bravedns.wireguard.Config import com.celzero.bravedns.wireguard.Peer +import com.celzero.bravedns.wireguard.WgHopManager import com.celzero.bravedns.wireguard.WgInterface import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch -import org.json.JSONObject import org.koin.core.component.KoinComponent import org.koin.core.component.inject -import retrofit2.converter.gson.GsonConverterFactory -import java.io.ByteArrayInputStream import java.io.File -import java.io.InputStream -import java.nio.charset.StandardCharsets -import java.util.Locale import java.util.concurrent.CopyOnWriteArraySet -import java.util.concurrent.TimeUnit object WireguardManager : KoinComponent { @@ -53,46 +49,44 @@ object WireguardManager : KoinComponent { private val applicationContext: Context by inject() private val appConfig: AppConfig by inject() + // 120 sec + 5 sec buffer + const val WG_HANDSHAKE_TIMEOUT = 125 * DateUtils.SECOND_IN_MILLIS + const val WG_UPTIME_THRESHOLD = 5 * DateUtils.SECOND_IN_MILLIS + // contains db values of wg configs (db stores path of the config file) private var mappings: CopyOnWriteArraySet = CopyOnWriteArraySet() - // contains the catch-all app config cache, so that we can use it for further requests - private val catchAllAppConfigCache: MutableStateFlow> = - MutableStateFlow(emptyMap()) - // contains parsed wg configs private var configs: CopyOnWriteArraySet = CopyOnWriteArraySet() // retrieve last added config id - private var lastAddedConfigId = 2 - - // warp response json keys - private const val JSON_RESPONSE_WORKS = "works" - private const val JSON_RESPONSE_REASON = "reason" + private var lastAddedConfigId = 0 - // warp primary and secondary config names, ids and file names - const val SEC_WARP_NAME = "SEC_WARP" - const val SEC_WARP_ID = 0 - const val SEC_WARP_FILE_NAME = "wg0.conf" - const val WARP_NAME = "WARP" - const val WARP_ID = 1 - const val WARP_FILE_NAME = "wg1.conf" + // let the error code be a string, so that it can be concatenated with the error message + const val ERR_CODE_VPN_NOT_ACTIVE = "1" + const val ERR_CODE_VPN_NOT_FULL = "2" + const val ERR_CODE_OTHER_WG_ACTIVE = "3" + const val ERR_CODE_WG_INVALID = "4" // invalid config id const val INVALID_CONF_ID = -1 - private val VALID_LAST_OK_SEC = TimeUnit.MINUTES.toMillis(3) + const val SEC_WARP_ID = 0 + const val SEC_WARP_NAME = "SEC_WARP" + + init { + io { load(forceRefresh = false) } + } - suspend fun load(): Int { - // clear the cached values - catchAllAppConfigCache.value = emptyMap() + suspend fun load(forceRefresh: Boolean): Int { + if (!forceRefresh && configs.isNotEmpty()) { + Logger.i(LOG_TAG_PROXY, "configs already loaded; returning...") + return configs.size + } // go through all files in the wireguard directory and load them // parse the files as those are encrypted // increment the id by 1, as the first config id is 0 - lastAddedConfigId = db.getLastAddedConfigId() + 1 - if (configs.isNotEmpty()) { - Logger.i(LOG_TAG_PROXY, "configs already loaded; refreshing...") - } + lastAddedConfigId = db.getLastAddedConfigId() val m = db.getWgConfigs().map { it.toImmutable() } mappings = CopyOnWriteArraySet(m) mappings.forEach { @@ -100,8 +94,12 @@ object WireguardManager : KoinComponent { val config = EncryptedFileManager.readWireguardConfig(applicationContext, path) if (config == null) { - Logger.e(LOG_TAG_PROXY, "error loading wg config: $path, deleting...") - db.deleteConfig(it.id) + Logger.e(LOG_TAG_PROXY, "err loading wg config: $path, invalid config") + // TODO: delete the warp config from the wireguard directory, now part of rpn proxy + // below code should be removed post v055o + if ((it.id == WARP_ID && it.name == WARP_NAME) || (it.id == SEC_WARP_ID && it.name == SEC_WARP_NAME)) { + deleteConfig(it.id) + } return@forEach } if (configs.none { i -> i.getId() == it.id }) { @@ -119,6 +117,22 @@ object WireguardManager : KoinComponent { return configs.size } + // remove this post v055o, sometimes the db update does not delete the entry, so adding this + // as precaution. + suspend fun deleteResidueWgs() { + val wgs = db.getWarpSecWarpConfig() + if (wgs.isEmpty()) { + Logger.i(LOG_TAG_PROXY, "no residue wg configs to delete") + return + } + wgs.forEach { + if (it.name == SEC_WARP_NAME || it.name == WARP_NAME) { + Logger.i(LOG_TAG_PROXY, "deleting residue wg config: ${it.id}, ${it.name}") + deleteConfig(it.id) + } + } + } + private fun clearLoadedConfigs() { configs.clear() mappings.clear() @@ -144,50 +158,47 @@ object WireguardManager : KoinComponent { return mappings.any { it.isActive } } - fun getEnabledConfigs(): List { + fun isAdvancedWgActive(): Boolean { + return mappings.any { it.isActive && !it.oneWireGuard } + } + + fun getAllMappings(): List { + return mappings.toList() + } + + fun getNumberOfMappings(): Int { + return mappings.size + } + + fun getActiveWgCount() = mappings.count { it.isActive } + + fun getActiveConfigs(): List { val m = mappings.filter { it.isActive } val l = mutableListOf() m.forEach { val config = configs.find { it1 -> it1.getId() == it.id } - if (config != null && !isWarp(config)) { + if (config != null) { l.add(config) } } return l } - private fun isWarp(config: Config): Boolean { - return config.getId() == WARP_ID || config.getId() == SEC_WARP_ID - } - fun isConfigActive(configId: String): Boolean { try { - val id = configId.split(ProxyManager.ID_WG_BASE).last().toIntOrNull() ?: return false + val id = configId.split(ID_WG_BASE).last().toIntOrNull() ?: return false val mapping = mappings.find { it.id == id } if (mapping != null) { return mapping.isActive } return false } catch (e: Exception) { - Logger.w(LOG_TAG_PROXY, "Exception while checking config active: ${e.message}") + Logger.w(LOG_TAG_PROXY, "err while checking active config: ${e.message}") } return false } - fun getWarpConfig(): Config? { - // warp config will always be the first config in the list - return configs.firstOrNull { it.getId() == WARP_ID } - } - - fun getSecWarpConfig(): Config? { - return configs.find { it.getId() == SEC_WARP_ID } - } - - fun isSecWarpAvailable(): Boolean { - return configs.any { it.getId() == SEC_WARP_ID } - } - - fun enableConfig(unmapped: WgConfigFilesImmutable) { + suspend fun enableConfig(unmapped: WgConfigFilesImmutable) { val map = mappings.find { it.id == unmapped.id } if (map == null) { Logger.e( @@ -198,9 +209,8 @@ object WireguardManager : KoinComponent { } val config = configs.find { it.getId() == map.id } - // no need to enable config if it is sec warp - if (config == null || config.getId() == SEC_WARP_ID) { - Logger.w(LOG_TAG_PROXY, "Config not found or is SEC_WARP: ${map.id}") + if (config == null) { + Logger.w(LOG_TAG_PROXY, "config not found: ${map.id}") return } @@ -216,39 +226,43 @@ object WireguardManager : KoinComponent { map.isCatchAll, map.isLockdown, map.oneWireGuard, + map.useOnlyOnMetered, map.isDeletable ) mappings.add(newMap) val dbMap = WgConfigFiles.fromImmutable(newMap) - io { db.update(dbMap) } + db.update(dbMap) val proxyType = AppConfig.ProxyType.WIREGUARD val proxyProvider = AppConfig.ProxyProvider.WIREGUARD appConfig.addProxy(proxyType, proxyProvider) - VpnController.addWireGuardProxy(ProxyManager.ID_WG_BASE + map.id) + VpnController.addWireGuardProxy(ID_WG_BASE + map.id) Logger.i(LOG_TAG_PROXY, "enable wg config: ${map.id}, ${map.name}") return } - fun canEnableConfig(map: WgConfigFilesImmutable): Boolean { - val canEnable = appConfig.canEnableProxy() && appConfig.canEnableWireguardProxy() - if (!canEnable) { - return false - } - // if one wireguard is enabled, don't allow to enable another - if (oneWireGuardEnabled()) { - return false - } - val config = configs.find { it.getId() == map.id } + fun canEnableProxy(): Boolean { + return appConfig.canEnableProxy() + } + + fun isValidConfig(id: Int): Boolean { + val config = configs.find { it.getId() == id } if (config == null) { - Logger.e(LOG_TAG_PROXY, "canEnableConfig: wg not found, id: ${map.id}, ${configs.size}") + Logger.e(LOG_TAG_PROXY, "canEnableConfig: wg not found, id: ${id}, ${configs.size}") return false } return true } + fun isAnyOtherOneWgEnabled(id: Int): Boolean { + return mappings.any { it.oneWireGuard && it.isActive && it.id != id } + } + fun canDisableConfig(map: WgConfigFilesImmutable): Boolean { // do not allow to disable the proxy if it is catch-all - return !map.isCatchAll + val catchAll = !map.isCatchAll + // do not allow if the config is already hopping + val isVia = WgHopManager.isAlreadyHop(ID_WG_BASE+map.id) + return !catchAll || !isVia } fun canDisableAllActiveConfigs(): Boolean { @@ -288,9 +302,8 @@ object WireguardManager : KoinComponent { } val config = configs.find { it.getId() == unmapped.id } - // no need to enable config if it is sec warp - if (config == null || config.getId() == SEC_WARP_ID) { - Logger.w(LOG_TAG_PROXY, "Config not found or is SEC_WARP: ${unmapped.id}") + if (config == null) { + Logger.w(LOG_TAG_PROXY, "config not found: ${unmapped.id}") return } @@ -307,6 +320,7 @@ object WireguardManager : KoinComponent { m.isCatchAll, m.isLockdown, false, // confirms with db.disableConfig query + m.useOnlyOnMetered, m.isDeletable ) mappings.add(newMap) @@ -318,262 +332,221 @@ object WireguardManager : KoinComponent { appConfig.removeProxy(proxyType, proxyProvider) } // directly remove the proxy from the tunnel, instead of calling updateTun - VpnController.removeWireGuardProxy(newMap.id) + io { VpnController.removeWireGuardProxy(newMap.id) } Logger.i(LOG_TAG_PROXY, "disable wg config: ${newMap.id}, ${newMap.name}") return } - suspend fun getNewWarpConfig(id: Int, retryCount: Int = 0): Config? { - try { - val privateKey = Backend.newWgPrivateKey() - val publicKey = privateKey.mult().base64() - val deviceName = android.os.Build.MODEL - val locale = Locale.getDefault().toString() - - val retrofit = - RetrofitManager.getWarpBaseBuilder(retryCount) - .addConverterFactory(GsonConverterFactory.create()) - .build() - val retrofitInterface = retrofit.create(IWireguardWarp::class.java) - - val response = retrofitInterface.getNewWarpConfig(publicKey, deviceName, locale) - Logger.d(LOG_TAG_PROXY, "New wg(warp) config: ${response?.body()}") - - if (response?.isSuccessful == true) { - val jsonObject = JSONObject(response.body().toString()) - val config = parseNewConfigJsonResponse(privateKey, jsonObject) - if (config != null) { - configs - .find { it.getId() == WARP_ID || it.getId() == SEC_WARP_ID } - ?.let { configs.remove(it) } - val c = - Config.Builder() - .setId(id) - .setName(if (id == WARP_ID) WARP_NAME else SEC_WARP_NAME) - .setInterface(config.getInterface()) - .addPeers(config.getPeers()) - .build() - configs.add(c) - - writeConfigAndUpdateDb(config, jsonObject.toString()) - } - return config - } - } catch (e: Exception) { - Logger.e(LOG_TAG_PROXY, "err: new wg(warp) config: ${e.message}") + // pair - first: proxyId, second - can proceed for next check + private suspend fun canUseConfig(idStr: String, type: String, usesMtrdNw: Boolean): Pair { + val block = Backend.Block + if (idStr.isEmpty()) { + Logger.d(LOG_TAG_PROXY, "config id is empty, return empty") + return Pair("", true) } - return if (isRetryRequired(retryCount)) { - Logger.i(Logger.LOG_TAG_DOWNLOAD, "retrying to getNewWarpConfig") - getNewWarpConfig(id, retryCount + 1) - } else { - Logger.i(LOG_TAG_PROXY, "retry count exceeded(getNewWarpConfig), returning null") - null - } - } + val id = convertStringIdToId(idStr) + val config = if (id == INVALID_CONF_ID) null else mappings.find { it.id == id } - private fun isRetryRequired(retryCount: Int): Boolean { - return retryCount < RetrofitManager.Companion.OkHttpDnsType.entries.size - 1 - } + if (config == null) { + Logger.d(LOG_TAG_PROXY, "config null no need to proceed, return empty") + return Pair("", true) + } - suspend fun isWarpWorking(retryCount: Int = 0): Boolean { - // create okhttp client with base url - var works = false - try { - val retrofit = - RetrofitManager.getWarpBaseBuilder(retryCount) - .addConverterFactory(GsonConverterFactory.create()) - .build() - val retrofitInterface = retrofit.create(IWireguardWarp::class.java) - - val response = retrofitInterface.isWarpConfigWorking() - Logger.d( - LOG_TAG_PROXY, - "new wg(warp) config: ${response?.headers()}, ${response?.message()}, ${response?.raw()?.request?.url}" - ) + if (config.isLockdown && checkEligibilityBasedOnNw(id, usesMtrdNw)) { + Logger.d(LOG_TAG_PROXY, "lockdown wg for $type => return $idStr") + return Pair(idStr, false) // no need to proceed further for lockdown + } - if (response?.isSuccessful == true) { - val jsonObject = JSONObject(response.body().toString()) - works = jsonObject.optBoolean(JSON_RESPONSE_WORKS, false) - val reason = jsonObject.optString(JSON_RESPONSE_REASON, "") - Logger.i( - LOG_TAG_PROXY, - "warp response for ${response.raw().request.url}, works? $works, reason: $reason" - ) - } else { - Logger.w( - LOG_TAG_PROXY, - "unsuccessful response for ${response?.raw()?.request?.url}" - ) - } - } catch (e: Exception) { - Logger.e(LOG_TAG_PROXY, "err checking warp(works): ${e.message}") + // in case of lockdown and not metered network, we need to return block as the + // lockdown should not leak the connections via WiFi + if (config.isLockdown) { + // add IpnBlock instead of the config id, let the connection be blocked in WiFi + // regardless of config is active or not + Logger.d(LOG_TAG_PROXY, "lockdown wg for $type => return $block") + return Pair(block, false) // no need to proceed further for lockdown } - return if (isRetryRequired(retryCount) && !works) { - Logger.i(Logger.LOG_TAG_DOWNLOAD, "retrying to getNewWarpConfig") - isWarpWorking(retryCount + 1) - } else { - Logger.i(LOG_TAG_PROXY, "retry count exceeded(getNewWarpConfig), returning null") - works + // check if the config is active and if it can be used on this network + if (config.isActive && checkEligibilityBasedOnNw(id, usesMtrdNw)) { + Logger.d(LOG_TAG_PROXY, "active wg for $type => add $idStr") + return Pair(idStr, true) } - } - suspend fun getConfigIdForApp(uid: Int, ip: String): WgConfigFilesImmutable? { - // this method does not account the settings "Bypass all proxies" which is app-specific - val configId = ProxyManager.getProxyIdForApp(uid) + return Pair("", true) + } - val id = if (configId.isNotEmpty()) convertStringIdToId(configId) else INVALID_CONF_ID - val config = if (id == INVALID_CONF_ID) null else mappings.find { it.id == id } + // no need to check for app excluded from proxy here, expected to call this fn after that + suspend fun getAllPossibleConfigIdsForApp(uid: Int, ip: String, port: Int, domain: String, usesMeteredNw: Boolean, default: String = ""): List { + val block = Backend.Block + val proxyIds: MutableList = mutableListOf() + if (oneWireGuardEnabled()) { + val id = getOneWireGuardProxyId() + if (id == null || id == INVALID_CONF_ID) { + Logger.e(LOG_TAG_PROXY, "canAdd: one-wg not found, id: $id, return ${emptyList()}") + return emptyList() + } - // if the app is added to config, return the config if it is active or lockdown - if (config != null && (config.isActive || config.isLockdown)) { - return config - } - // check if any catch-all config is enabled - if (configId == "" || !configId.contains(ProxyManager.ID_WG_BASE) || config == null) { - Logger.d(LOG_TAG_PROXY, "app config mapping not found for uid: $uid") - // there maybe catch-all config enabled, so return the active catch-all config - val catchAllConfig = mappings.find { it.isActive && it.isCatchAll } - return if (catchAllConfig == null) { - Logger.d(LOG_TAG_PROXY, "catch all config not found for uid: $uid") - null + // commenting this as the one-wg is enabled for all networks no need to check for + // mobile network, uncomment this when the one-wg can have mobile only option + /*if (checkEligibilityBasedOnNw(id, usesMeteredNw)) { + proxyIds.add(ID_WG_BASE + id) + // add default to the list, can route check is done in go-tun + if (default.isNotEmpty()) proxyIds.add(default) + Logger.i(LOG_TAG_PROXY, "one-wg enabled, return $proxyIds") + return proxyIds } else { - val optimalId = fetchOptimalCatchAllConfig(uid, ip) - if (optimalId == null) { - Logger.d(LOG_TAG_PROXY, "no catch all config found for uid: $uid") - null - } else { - Logger.d(LOG_TAG_PROXY, "catch all config found for uid: $uid, $optimalId") - mappings.find { it.id == optimalId } - } + // fall-through as one-wg is enabled only for metered networks + // for now the setting doesn't allow user to set the one-wg to mobile networks + // so this case is not expected + }*/ + proxyIds.add(ID_WG_BASE + id) + // add default to the list, can route check is done in go-tun + if (default.isNotEmpty()) proxyIds.add(default) + Logger.i(LOG_TAG_PROXY, "one-wg enabled, return $proxyIds") + return proxyIds + } + + // check for ip-app specific config first + // returns Pair - first is ProxyId, second is CC + val ipc = IpRulesManager.hasProxy(uid, ip, port) + // return Pair - first is ProxyId, second is can proceed for next check + // one case where second parameter is true when the config is in lockdown mode + val ipcProxyPair = canUseConfig(ipc.first, "ip($ip:$port)", usesMeteredNw) + if (!ipcProxyPair.second) { // false denotes first is not empty + if (ipcProxyPair.first == block) { + proxyIds.clear() + proxyIds.add(block) + } else { + proxyIds.add(ipcProxyPair.first) } + Logger.i(LOG_TAG_PROXY, "lockdown wg for ip($ip:$port) => return $proxyIds") + return proxyIds + } + // add the ip-app specific config to the list + if (ipc.first.isNotEmpty()) proxyIds.add(ipc.first) // ip-app specific + + // check for domain-app specific config + val dc = DomainRulesManager.getProxyForDomain(uid, domain) + val dcProxyPair = canUseConfig(dc.first, "domain($domain)", usesMeteredNw) + if (!dcProxyPair.second) { + if (ipcProxyPair.first == block) { + proxyIds.clear() + proxyIds.add(block) + } else { + proxyIds.add(ipcProxyPair.first) + } + Logger.i(LOG_TAG_PROXY, "lockdown wg for domain($domain) => return $proxyIds") + return proxyIds + } + // add the domain-app specific config to the list + if (dcProxyPair.first.isNotEmpty()) proxyIds.add(dcProxyPair.first) // domain-app specific + + // check for app specific config + val ac = ProxyManager.getProxyIdForApp(uid) + val appProxyPair = canUseConfig(ac, "app($uid)", usesMeteredNw) + if (!appProxyPair.second) { + if (appProxyPair.first == block) { + proxyIds.clear() + proxyIds.add(block) + } else { + proxyIds.add(appProxyPair.first) + } + Logger.i(LOG_TAG_PROXY, "lockdown wg for app($uid) => return $proxyIds") + return proxyIds } - // if the app is not added to any config, and no catch-all config is enabled - return null - } - - private fun convertStringIdToId(id: String): Int { - return try { - val configId = id.substring(ProxyManager.ID_WG_BASE.length) - configId.toIntOrNull() ?: INVALID_CONF_ID - } catch (e: Exception) { - Logger.i(LOG_TAG_PROXY, "err converting string id to int: $id") - INVALID_CONF_ID - } - } - - fun clearCatchAllCache() { - catchAllAppConfigCache.value = emptyMap() - } + // add the app specific config to the list + if (appProxyPair.first.isNotEmpty()) proxyIds.add(appProxyPair.first) - fun clearCatchAllCacheForApp(wgId: String) { - if (wgId.isEmpty()) { - Logger.e(LOG_TAG_PROXY, "clearCache: empty wgId") - return + // check for universal ip config + val uipc = IpRulesManager.hasProxy(UID_EVERYBODY, ip, port) + val uipcProxyPair = canUseConfig(uipc.first, "univ-ip($ip:$port)", usesMeteredNw) + if (!uipcProxyPair.second) { + if (ipcProxyPair.first == block) { + proxyIds.clear() + proxyIds.add(block) + } else { + proxyIds.add(ipcProxyPair.first) + } + Logger.i(LOG_TAG_PROXY, "lockdown wg for univ-ip($ip:$port) => return $proxyIds") + return proxyIds // no need to proceed further for lockdown } - val id = convertStringIdToId(wgId) - if (id == INVALID_CONF_ID) { - Logger.e(LOG_TAG_PROXY, "clearCache: invalid wgId: $wgId") - } - val uids = catchAllAppConfigCache.value.filterValues { it == id }.keys - uids.forEach { catchAllAppConfigCache.value -= it } - } + // add the universal ip config to the list + if (uipcProxyPair.first.isNotEmpty()) proxyIds.add(uipcProxyPair.first) // universal ip - private suspend fun fetchOptimalCatchAllConfig(uid: Int, ip: String): Int? { - val available = catchAllAppConfigCache.value.containsKey(uid) - if (available) { - val wgId = catchAllAppConfigCache.value[uid] - if (wgId != null) { - if (isProxyConnectionValid(wgId, ip)) { - Logger.d(LOG_TAG_PROXY, "optimalCatchAllConfig: returning cached wgId: $wgId") - return wgId // return the already mapped wgId which is active - } + // check for universal domain config + val udc = DomainRulesManager.getProxyForDomain(UID_EVERYBODY, domain) + val udcProxyPair = canUseConfig(udc.first, "univ-dom($domain)", usesMeteredNw) + if (!udcProxyPair.second) { + if (ipcProxyPair.first == block) { + proxyIds.clear() + proxyIds.add(block) } else { - // pass-through + proxyIds.add(ipcProxyPair.first) } + Logger.i(LOG_TAG_PROXY, "lockdown wg for univ-dom($domain) => return $proxyIds") + return proxyIds // no need to proceed further for lockdown } - Logger.d(LOG_TAG_PROXY, "optimalCatchAllConfig: fetching new wgId for uid: $uid") - val catchAllList = mappings.filter { - val id = ProxyManager.ID_WG_BASE + it.id - it.isActive && it.isCatchAll && VpnController.canRouteIp(id, ip, false) } - catchAllList.forEach { - if (isProxyConnectionValid(it.id, ip)) { - // note the uid and wgid in a cache, so that we can use it for further requests - catchAllAppConfigCache.value += uid to it.id - Logger.d(LOG_TAG_PROXY, "optimalCatchAllConfig: returning new wgId: ${it.id}") - return it.id - } - } - // none of the catch-all has valid connection, send ping to all catch-all configs - pingCatchAllConfigs(catchAllList) - // return any catch-all config - return catchAllList.randomOrNull()?.id - } - private fun pingCatchAllConfigs(catchAllConfigs: List) { - io { - // ping the catch-all config - catchAllConfigs.forEach { - val id = ProxyManager.ID_WG_BASE + it.id - VpnController.initiateWgPing(id) + // add the universal domain config to the list + if (udcProxyPair.first.isNotEmpty()) proxyIds.add(udcProxyPair.first) + + // once the app-specific config is added, check if any catch-all config is enabled + // if catch-all config is enabled, then add the config id to the list + val cac = mappings.filter { it.isActive && it.isCatchAll } + cac.forEach { + if (checkEligibilityBasedOnNw(it.id, usesMeteredNw)) { + proxyIds.add(ID_WG_BASE + it.id) + Logger.i( + LOG_TAG_PROXY, + "catch-all config is active: ${it.id}, ${it.name} => add ${ID_WG_BASE + it.id}" + ) } } - } - private suspend fun isProxyConnectionValid(wgId: Int, ip: String, default: Boolean = false): Boolean { - // check if the handshake is less than 3 minutes (VALID_LAST_OK_SEC) - // and if the ip can be routed - val id = ProxyManager.ID_WG_BASE + wgId - val canRoute = VpnController.canRouteIp(id, ip, default) - Logger.d(LOG_TAG_PROXY, "isProxyConnectionValid: $wgId? can route?$canRoute") - return isValidLastOk(wgId) && canRoute - } + // add the default proxy to the end, will not be true for lockdown but lockdown is handled + // above, so no need to check here + if (default.isNotEmpty()) proxyIds.add(default) - private suspend fun isValidLastOk(wgId: Int): Boolean { - val id = ProxyManager.ID_WG_BASE + wgId - val stat = VpnController.getProxyStats(id) ?: return false - val lastOk = stat.lastOK - Logger.d(LOG_TAG_PROXY, "isValidLastOk: $wgId? lastOk: $lastOk") - return (System.currentTimeMillis() - lastOk) < VALID_LAST_OK_SEC + // the proxyIds list will contain the ip-app specific, domain-app specific, app specific, + // universal ip, universal domain, catch-all and default configs in the order of priority + // the go-tun will check the routing based on the order of the list + Logger.i(LOG_TAG_PROXY, "returning proxy ids for $uid, $ip, $port, $domain: $proxyIds") + return proxyIds } - private fun parseNewConfigJsonResponse(privateKey: WgKey, jsonObject: JSONObject?): Config? { - // get the json tag "wgconf" from the response - if (jsonObject == null) { - Logger.e(LOG_TAG_PROXY, "new warp config json object is null") - return null + // only when config is set to use on mobile network and current network is not mobile + // then return false, all other cases return true + private fun checkEligibilityBasedOnNw(id: Int, usesMobileNw: Boolean): Boolean { + val config = mappings.find { it.id == id } + if (config == null) { + Logger.e(LOG_TAG_PROXY, "canAdd: wg not found, id: $id, ${mappings.size}") + return false } - val jsonConfObject = jsonObject.optString("wgconf") - // add the private key to the config after the term [Interface] - val conf = - jsonConfObject.replace( - "[Interface]", - "[Interface]\nPrivateKey = ${privateKey.base64()}" - ) - // convert string to inputstream - val configStream: InputStream = - ByteArrayInputStream(conf.toByteArray(StandardCharsets.UTF_8)) + if (config.useOnlyOnMetered && !usesMobileNw) { + Logger.i(LOG_TAG_PROXY, "canAdd: useOnlyOnMetered is true, but not metered nw") + return false + } - val cfg = - try { - Config.parse(configStream) - } catch (e: BadConfigException) { - Logger.e( - LOG_TAG_PROXY, - "err parsing config: ${e.message}, ${e.reason}, ${e.text}, ${e.location}, ${e.section}, ${e.stackTrace}, ${e.cause}" - ) - null - } - Logger.i(LOG_TAG_PROXY, "New wireguard config: ${cfg?.getName()}, ${cfg?.getId()}") - return cfg + Logger.d(LOG_TAG_PROXY, "canAdd: eligible for metered nw: $usesMobileNw") + return true + } + + private fun convertStringIdToId(id: String): Int { + return try { + val configId = id.substring(ID_WG_BASE.length) + configId.toIntOrNull() ?: INVALID_CONF_ID + } catch (ignored: Exception) { + Logger.i(LOG_TAG_PROXY, "err converting string id to int: $id") + INVALID_CONF_ID + } } suspend fun addConfig(config: Config?, name: String = ""): Config? { if (config == null) { - Logger.e(LOG_TAG_PROXY, "error adding config") + Logger.e(LOG_TAG_PROXY, "err adding config") return null } // increment the id and add the config @@ -588,7 +561,7 @@ object WireguardManager : KoinComponent { .addPeers(config.getPeers()) .build() writeConfigAndUpdateDb(cfg) - Logger.d(LOG_TAG_PROXY, "add config: ${config.getId()}, ${config.getName()}") + Logger.d(LOG_TAG_PROXY, "config added: ${config.getId()}, ${config.getName()}") return config } @@ -610,8 +583,8 @@ object WireguardManager : KoinComponent { val id = lastAddedConfigId val name = configName.ifEmpty { "wg$id" } val cfg = Config.Builder().setId(id).setName(name).setInterface(wgInterface).build() - Logger.d(LOG_TAG_PROXY, "adding interface for config: $id, $name") writeConfigAndUpdateDb(cfg) + Logger.d(LOG_TAG_PROXY, "interface added for config: $id, $name") return cfg } @@ -635,7 +608,7 @@ object WireguardManager : KoinComponent { .addPeers(config.getPeers()) .build() Logger.i(LOG_TAG_PROXY, "updating interface for config: $configId, ${config.getName()}") - val cfgId = ProxyManager.ID_WG_BASE + configId + val cfgId = ID_WG_BASE + configId if (configName != config.getName()) { ProxyManager.updateProxyNameForProxyId(cfgId, configName) } @@ -650,14 +623,7 @@ object WireguardManager : KoinComponent { fun deleteConfig(id: Int) { val cf = mappings.find { it.id == id } Logger.i(LOG_TAG_PROXY, "deleteConfig start: $id, ${cf?.name}, ${cf?.configPath}") - mappings.forEach { - Logger.i(LOG_TAG_PROXY, "deleteConfig: ${it.id}, ${it.name}, ${it.configPath}") - } - val canDelete = cf?.isDeletable ?: false - if (!canDelete) { - Logger.e(LOG_TAG_PROXY, "wg config not deletable for id: $id") - return - } + // delete the config file val config = configs.find { it.getId() == id } if (cf?.isActive == true) { @@ -681,10 +647,11 @@ object WireguardManager : KoinComponent { } // delete the config from the database db.deleteConfig(id) - val proxyId = ProxyManager.ID_WG_BASE + id + val proxyId = ID_WG_BASE + id ProxyManager.removeProxyId(proxyId) mappings.remove(mappings.find { it.id == id }) configs.remove(config) + WgHopManager.handleWgDelete(id) } } @@ -709,11 +676,12 @@ object WireguardManager : KoinComponent { m.isCatchAll, isLockdown, // just updating lockdown field m.oneWireGuard, + m.useOnlyOnMetered, m.isDeletable ) ) if (map?.isActive == true) { - VpnController.addWireGuardProxy(id = ProxyManager.ID_WG_BASE + config.getId()) + VpnController.addWireGuardProxy(id = ID_WG_BASE + config.getId()) } } @@ -737,6 +705,7 @@ object WireguardManager : KoinComponent { isEnabled, // just updating catch all field m.isLockdown, m.oneWireGuard, + m.useOnlyOnMetered, m.isDeletable ) mappings.add(newMap) @@ -764,11 +733,40 @@ object WireguardManager : KoinComponent { m.isCatchAll, m.isLockdown, owg, // updating just one wireguard field + m.useOnlyOnMetered, m.isDeletable ) ) } + suspend fun updateUseOnMobileNetworkConfig(id: Int, useMobileNw: Boolean) { + val config = configs.find { it.getId() == id } + if (config == null) { + Logger.e(LOG_TAG_PROXY, "update useMobileNw: wg not found, id: $id, ${configs.size}") + return + } + Logger.i(LOG_TAG_PROXY, "updating useMobileNw as $useMobileNw for config: $id, ${config.getName()}") + db.updateCatchAllConfig(id, useMobileNw) + val m = mappings.find { it.id == id } ?: return + mappings.remove(m) + val newMap = + WgConfigFilesImmutable( + id, + config.getName(), + m.configPath, + m.serverResponse, + m.isActive, + m.isCatchAll, // just updating catch all field + m.isLockdown, + m.oneWireGuard, + useMobileNw, + m.isDeletable + ) + mappings.add(newMap) + + enableConfig(newMap) // catch all should be always enabled + } + suspend fun addPeer(id: Int, peer: Peer) { // add the peer to the config val cfg: Config @@ -833,10 +831,6 @@ object WireguardManager : KoinComponent { EncryptedFileManager.writeWireguardConfig(applicationContext, parsedCfg, fileName) val path = getConfigFilePath() + fileName Logger.i(LOG_TAG_PROXY, "writing wg config to file: $path") - // no need to write the config to the database if it is default config / WARP - if (cfg.getId() == WARP_ID || cfg.getId() == SEC_WARP_ID) { - return - } val file = db.isConfigAdded(cfg.getId()) if (file == null) { val wgf = @@ -848,7 +842,8 @@ object WireguardManager : KoinComponent { isActive = false, isCatchAll = false, isLockdown = false, - oneWireGuard = false + oneWireGuard = false, + useOnlyOnMetered = false ) db.insert(wgf) } else { @@ -860,7 +855,7 @@ object WireguardManager : KoinComponent { addOrUpdateConfigFileMapping(cfg, file?.toImmutable(), path, serverResponse) addOrUpdateConfig(cfg) if (file?.isActive == true) { - VpnController.addWireGuardProxy(id = ProxyManager.ID_WG_BASE + cfg.getId()) + VpnController.addWireGuardProxy(id = ID_WG_BASE + cfg.getId()) } } @@ -891,7 +886,8 @@ object WireguardManager : KoinComponent { isCatchAll = false, isLockdown = false, oneWireGuard = false, - isDeletable = true + isDeletable = true, + useOnlyOnMetered = false ) mappings.add(wgf) } else { @@ -912,15 +908,54 @@ object WireguardManager : KoinComponent { return configs.find { it.getId() == id }?.getPeers()?.toMutableList() ?: mutableListOf() } - fun restoreProcessDeleteWireGuardEntries() { - // during a restore, we do not posses the keys to decrypt the wireguard configs - // so, delete the wireguard configs carried over from the backup - io { - val count = db.deleteOnAppRestore() - ProxyManager.removeWgProxies() - Logger.i(LOG_TAG_PROXY, "Deleted wg entries: $count") - clearLoadedConfigs() - load() + suspend fun restoreProcessRetrieveWireGuardConfigs() { + val count = db.getWgConfigs().size + Logger.i(LOG_TAG_PROXY, "restored wg entries count: $count") + clearLoadedConfigs() + performRestore() + load(forceRefresh = true) + } + + suspend fun performRestore() { + // during restore process, plain text wg configs are present in the temp dir + // move the files to the wireguard directory and load the configs + val tempDir = File(applicationContext.filesDir, TEMP_WG_DIR) + val dbconfs = db.getWgConfigs() + Logger.v(LOG_TAG_PROXY, "temp dir: ${tempDir.listFiles()?.size}, db size: ${dbconfs.size}") + dbconfs.forEach { c -> + // for each database entry, corresponding file with $id.conf is present in the temp dir + // move the file to the wireguard directory with the name available in the database + val file = File(tempDir, "${c.id}.conf") + if (file.exists()) { + Logger.i(LOG_TAG_PROXY, "file exists: ${file.absolutePath}, proceed restore") + } else { + Logger.i(LOG_TAG_PROXY, "no wg file, delete config: ${file.absolutePath}") + db.deleteConfig(c.id) + return@forEach + } + // read the contents of the file and write it to the EncryptedFileManager + val bytes = file.readBytes() + val encryptFile = File(c.configPath) + if (!encryptFile.exists()) { + encryptFile.parentFile?.mkdirs() + encryptFile.createNewFile() + } + val res = EncryptedFileManager.write(applicationContext, bytes, encryptFile) + if (res) { + Logger.i(LOG_TAG_PROXY, "restored wg config: ${c.id}, ${c.name}") + } else { + Logger.e(LOG_TAG_PROXY, "err restoring wg config: ${c.id}, ${c.name}") + // in case of error, delete the entry from the database + db.deleteConfig(c.id) + } + } + + val isResidueDeleted = Utilities.deleteRecursive(tempDir) + if (isResidueDeleted) { + Logger.i(LOG_TAG_PROXY, "deleted residue temp wg files: ${tempDir.absolutePath}") + } else { + Logger.w(LOG_TAG_PROXY, "failed to delete residue temp wg files: ${tempDir.absolutePath}") + tempDir.deleteRecursively() } } @@ -928,27 +963,12 @@ object WireguardManager : KoinComponent { return mappings.any { it.oneWireGuard && it.isActive } } - fun catchAllEnabled(): Boolean { - return mappings.any { it.isCatchAll && it.isActive } - } - fun getOneWireGuardProxyId(): Int? { return mappings.find { it.oneWireGuard && it.isActive }?.id } - suspend fun getOptimalCatchAllConfigId(ip: String?): Int? { - val configs = mappings.filter { - val id = ProxyManager.ID_WG_BASE + it.id - it.isCatchAll && it.isActive && ((ip == null) || VpnController.canRouteIp(id, ip, false)) } - configs.forEach { - if (isValidLastOk(it.id)) { - Logger.d(LOG_TAG_PROXY, "found optimal catch all config: ${it.id}") - return it.id - } - } - Logger.d(LOG_TAG_PROXY, "no optimal catch all config found, returning any catchall") - // if no catch-all config is active, return any catch-all config - return configs.randomOrNull()?.id + fun getActiveCatchAllConfig(): List { + return mappings.filter { it.isActive && it.isCatchAll } } private fun io(f: suspend () -> Unit) { diff --git a/app/src/full/java/com/celzero/bravedns/ui/HomeScreenActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/HomeScreenActivity.kt index aa2d79071..ce353f36c 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/HomeScreenActivity.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/HomeScreenActivity.kt @@ -29,13 +29,15 @@ import android.content.res.Configuration import android.content.res.Configuration.UI_MODE_NIGHT_YES import android.net.Uri import android.os.Bundle -import android.os.PersistableBundle import android.os.SystemClock +import android.view.View import android.widget.Toast import androidx.appcompat.app.AppCompatActivity -import androidx.biometric.BiometricManager -import androidx.biometric.BiometricPrompt import androidx.core.content.ContextCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.core.view.updatePadding import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.NavHostFragment import androidx.navigation.ui.setupWithNavController @@ -54,7 +56,7 @@ import com.celzero.bravedns.backup.BackupHelper.Companion.BACKUP_FILE_EXTN import com.celzero.bravedns.backup.BackupHelper.Companion.INTENT_RESTART_APP import com.celzero.bravedns.backup.BackupHelper.Companion.INTENT_SCHEME import com.celzero.bravedns.backup.RestoreAgent -import com.celzero.bravedns.data.AppConfig +import com.celzero.bravedns.database.AppInfoRepository import com.celzero.bravedns.database.RefreshDatabase import com.celzero.bravedns.databinding.ActivityHomeScreenBinding import com.celzero.bravedns.service.AppUpdater @@ -62,6 +64,8 @@ import com.celzero.bravedns.service.BraveVPNService import com.celzero.bravedns.service.PersistentState import com.celzero.bravedns.service.RethinkBlocklistManager import com.celzero.bravedns.service.VpnController +import com.celzero.bravedns.service.WireguardManager +import com.celzero.bravedns.ui.activity.MiscSettingsActivity import com.celzero.bravedns.ui.activity.PauseActivity import com.celzero.bravedns.ui.activity.WelcomeActivity import com.celzero.bravedns.util.Constants @@ -70,39 +74,31 @@ import com.celzero.bravedns.util.RemoteFileTagUtil import com.celzero.bravedns.util.Themes.Companion.getCurrentTheme import com.celzero.bravedns.util.Utilities import com.celzero.bravedns.util.Utilities.getPackageMetadata +import com.celzero.bravedns.util.Utilities.isAtleastO_MR1 +import com.celzero.bravedns.util.Utilities.isAtleastQ import com.celzero.bravedns.util.Utilities.isPlayStoreFlavour import com.celzero.bravedns.util.Utilities.isWebsiteFlavour import com.celzero.bravedns.util.Utilities.showToastUiCentered import com.google.android.material.bottomnavigation.BottomNavigationView import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar -import java.util.Calendar -import java.util.concurrent.Executor -import java.util.concurrent.TimeUnit import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.koin.android.ext.android.get import org.koin.android.ext.android.inject +import java.util.Calendar +import java.util.concurrent.TimeUnit +import androidx.core.net.toUri class HomeScreenActivity : AppCompatActivity(R.layout.activity_home_screen) { - private val b by viewBinding(ActivityHomeScreenBinding::bind) - private val persistentState by inject() - private val appConfig by inject() + private val appInfoDb by inject() private val appUpdateManager by inject() private val rdb by inject() - // support for biometric authentication - private lateinit var executor: Executor - private lateinit var biometricPrompt: BiometricPrompt - private lateinit var promptInfo: BiometricPrompt.PromptInfo - - // private var biometricPromptRetryCount = 1 - private var onResumeCalledAlready = false - - companion object { - private const val ON_RESUME_CALLED_PREFERENCE_KEY = "onResumeCalled" - } + // TODO: see if this can be replaced with a more robust solution + // keep track of when app went to background + private var appInBackground = false // TODO - #324 - Usage of isDarkTheme() in all activities. private fun Context.isDarkThemeOn(): Boolean { @@ -114,15 +110,25 @@ class HomeScreenActivity : AppCompatActivity(R.layout.activity_home_screen) { setTheme(getCurrentTheme(isDarkThemeOn(), persistentState.theme)) super.onCreate(savedInstanceState) - // stackoverflow.com/questions/44221195/multiple-onstop-onresume-calls-in-android-activity - // Restore value of members from saved state - onResumeCalledAlready = - savedInstanceState?.getBoolean(ON_RESUME_CALLED_PREFERENCE_KEY) ?: false + if (isAtleastO_MR1()) { + Logger.vv(LOG_TAG_UI, "Setting up window insets for Android 27+") + ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.nav_view)) { view, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.updatePadding(bottom = systemBars.bottom) // Add bottom padding to keep icons visible + insets + WindowInsetsCompat.CONSUMED + } + } + + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } // do not launch on board activity when app is running on TV if (persistentState.firstTimeLaunch && !isAppRunningOnTv()) { launchOnboardActivity() - rdnsRemote() return } updateNewVersion() @@ -137,15 +143,20 @@ class HomeScreenActivity : AppCompatActivity(R.layout.activity_home_screen) { observeAppState() } - override fun onSaveInstanceState(outState: Bundle, outPersistentState: PersistableBundle) { - outState.putBoolean(ON_RESUME_CALLED_PREFERENCE_KEY, onResumeCalledAlready) - super.onSaveInstanceState(outState, outPersistentState) + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + setIntent(intent) + // by simply receiving and setting the new intent, we ensure that when the activity + // is brought back to the foreground, it uses the latest intent state + Logger.v(LOG_TAG_UI, "home screen activity received new intent") } override fun onResume() { super.onResume() - if (persistentState.biometricAuth && !isAppRunningOnTv() && !onResumeCalledAlready) { - biometricPrompt() + // if app is coming from background, don't reset the activity stack + if (appInBackground) { + appInBackground = false + Logger.d(LOG_TAG_UI, "app restored from background, maintaining activity stack") } } @@ -159,111 +170,6 @@ class HomeScreenActivity : AppCompatActivity(R.layout.activity_home_screen) { } } - private fun biometricPrompt() { - // if the biometric authentication is already done in the last 15 minutes, then skip - // fixme - #324 - move the 15 minutes to a configurable value - if ( - SystemClock.elapsedRealtime() - persistentState.biometricAuthTime < - TimeUnit.MINUTES.toMillis(15) - ) { - return - } - - promptInfo = - BiometricPrompt.PromptInfo.Builder() - .setTitle(getString(R.string.hs_biometeric_title)) - .setSubtitle(getString(R.string.hs_biometeric_desc)) - .setAllowedAuthenticators( - BiometricManager.Authenticators.BIOMETRIC_WEAK or - BiometricManager.Authenticators.DEVICE_CREDENTIAL - ) - .setConfirmationRequired(false) - .build() - - // ref: https://developer.android.com/training/sign-in/biometric-auth - executor = ContextCompat.getMainExecutor(this) - biometricPrompt = - BiometricPrompt( - this, - executor, - object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { - super.onAuthenticationError(errorCode, errString) - Logger.i( - LOG_TAG_UI, - "Biometric authentication error (code: $errorCode): $errString" - ) - // error code 5 (ERROR_CANCELED), this may happen when the device is locked - // or another pending operation prevents or disables it - // error code 10 (ERROR_USER_CANCELED), retry once after user cancelled - // the biometric prompt. ref issuetracker.google.com/issues/145231213 - // commenting the code below, as the retry is buggy and not working as - // expected, have to revisit this code later - /* if ( - biometricPromptRetryCount > 0 && - (errorCode == BiometricPrompt.ERROR_CANCELED || - errorCode == BiometricPrompt.ERROR_USER_CANCELED) - ) { - biometricPromptRetryCount-- - if (isInForeground()) biometricPrompt.authenticate(promptInfo) - } else { - showToastUiCentered( - applicationContext, - errString.toString(), - Toast.LENGTH_SHORT - ) - finish() - } */ - showToastUiCentered( - this@HomeScreenActivity, - errString.toString(), - Toast.LENGTH_SHORT - ) - finish() - } - - override fun onAuthenticationSucceeded( - result: BiometricPrompt.AuthenticationResult - ) { - super.onAuthenticationSucceeded(result) - // biometricPromptRetryCount = 1 - persistentState.biometricAuthTime = SystemClock.elapsedRealtime() - Logger.i(LOG_TAG_UI, "Biometric success @ ${System.currentTimeMillis()}") - } - - override fun onAuthenticationFailed() { - super.onAuthenticationFailed() - showToastUiCentered( - this@HomeScreenActivity, - getString(R.string.hs_biometeric_failed), - Toast.LENGTH_SHORT - ) - Logger.i(LOG_TAG_UI, "Biometric authentication failed") - // show the biometric prompt again only if the ui is in foreground - if (isInForeground()) biometricPrompt.authenticate(promptInfo) - } - } - ) - - // BIOMETRIC_WEAK :Any biometric (e.g. fingerprint, iris, or face) on the device that meets - // or exceeds the requirements for Class 2(formerly Weak), as defined by the Android CDD. - if ( - BiometricManager.from(this) - .canAuthenticate( - BiometricManager.Authenticators.BIOMETRIC_WEAK or - BiometricManager.Authenticators.DEVICE_CREDENTIAL - ) == BiometricManager.BIOMETRIC_SUCCESS - ) { - biometricPrompt.authenticate(promptInfo) - } else { - showToastUiCentered( - applicationContext, - getString(R.string.hs_biometeric_feature_not_supported), - Toast.LENGTH_SHORT - ) - } - } - private fun isInForeground(): Boolean { return !this.isFinishing && !this.isDestroyed } @@ -381,6 +287,12 @@ class HomeScreenActivity : AppCompatActivity(R.layout.activity_home_screen) { } private fun removeThisMethod() { + // set allowBypass to false for all versions, overriding the user's preference. + // the default was true for Play Store and website versions, and false for F-Droid. + // when allowBypass is true, some OEMs bypass the VPN service, causing connections + // to fail due to the "Block connections without VPN" option. + persistentState.allowBypass = false + // change the persistent state for defaultDnsUrl, if its google.com (only for v055d) // fixme: remove this post v054. // this is to fix the default dns url, as the default dns url is changed from @@ -390,21 +302,18 @@ class HomeScreenActivity : AppCompatActivity(R.layout.activity_home_screen) { persistentState.defaultDnsUrl = Constants.DEFAULT_DNS_LIST[2].url } moveRemoteBlocklistFileFromAsset() - // reset the bio metric auth time, as now the value is changed from System.currentTimeMillis - // to SystemClock.elapsedRealtime - persistentState.biometricAuthTime = SystemClock.elapsedRealtime() - } - - private fun rdnsRemote() { - // enforce the dns to sky for play store build, and max for website and f-droid build - // on first time launch - io { - if (isPlayStoreFlavour()) { - appConfig.switchRethinkDnsToSky() - } else { - appConfig.switchRethinkDnsToMax() - } + // set the rethink app in firewall mode as allowed by default + io { appInfoDb.resetRethinkAppFirewallMode() } + // if biometric auth is enabled, then set the biometric auth type to 3 (15 minutes) + if (persistentState.biometricAuth) { + persistentState.biometricAuthType = MiscSettingsActivity.BioMetricType.FIFTEEN_MIN.action + // reset the bio metric auth time, as now the value is changed from System.currentTimeMillis + // to SystemClock.elapsedRealtime + persistentState.biometricAuthTime = SystemClock.elapsedRealtime() } + + // delete residue wgs from database, remove this post v055o + io { WireguardManager.deleteResidueWgs() } } // fixme: find a cleaner way to implement this, move this to some other place @@ -429,7 +338,6 @@ class HomeScreenActivity : AppCompatActivity(R.layout.activity_home_screen) { private fun launchOnboardActivity() { val intent = Intent(this, WelcomeActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED startActivity(intent) finish() } @@ -438,12 +346,11 @@ class HomeScreenActivity : AppCompatActivity(R.layout.activity_home_screen) { if (!isNewVersion()) return val version = getLatestVersion() - Logger.i(LOG_TAG_UI, "New version detected, updating the app version, version: $version") + Logger.i(LOG_TAG_UI, "new version detected, updating the app version, version: $version") persistentState.appVersion = version persistentState.showWhatsNewChip = true // FIXME: remove this post v054 - // this is to fix the local blocklist default download location removeThisMethod() } @@ -455,7 +362,15 @@ class HomeScreenActivity : AppCompatActivity(R.layout.activity_home_screen) { private fun getLatestVersion(): Int { val pInfo: PackageInfo? = getPackageMetadata(this.packageManager, this.packageName) - return pInfo?.versionCode ?: 0 + // TODO: modify this to use the latest version code api + val v = pInfo?.versionCode ?: 0 + // latest version has apk variant (baseAbiVersionCode * 10000000 + variant.versionCode) + // so we need to mod the version code by 10000000 to get the actual version code + // for example: 10000000 + 45 = 10000045, so the version code is 1 + // see build.gradle (:app), #project.ext.versionCodes + val latestVersionCode = v % 10000000 // 10000000 is the base version code + Logger.i(LOG_TAG_UI, "latest version code: $latestVersionCode") + return latestVersionCode } // FIXME - Move it to Android's built-in WorkManager @@ -603,17 +518,22 @@ class HomeScreenActivity : AppCompatActivity(R.layout.activity_home_screen) { } private fun showUpdateCompleteSnackbar() { - val snack = - Snackbar.make( - b.container, - getString(R.string.update_complete_snack_message), - Snackbar.LENGTH_INDEFINITE - ) - snack.setAction(getString(R.string.update_complete_action_snack)) { - appUpdateManager.completeUpdate() + try { + val container: View = findViewById(R.id.container) + val snack = + Snackbar.make( + container, + getString(R.string.update_complete_snack_message), + Snackbar.LENGTH_INDEFINITE + ) + snack.setAction(getString(R.string.update_complete_action_snack)) { + appUpdateManager.completeUpdate() + } + snack.setActionTextColor(ContextCompat.getColor(this, R.color.primaryLightColorText)) + snack.show() + } catch (e: Exception) { + Logger.e(LOG_TAG_UI, "Error showing update complete snackbar: ${e.message}", e) } - snack.setActionTextColor(ContextCompat.getColor(this, R.color.primaryLightColorText)) - snack.show() } private fun showDownloadDialog( @@ -667,7 +587,7 @@ class HomeScreenActivity : AppCompatActivity(R.layout.activity_home_screen) { private fun initiateDownload() { try { val url = Constants.RETHINK_APP_DOWNLOAD_LINK - val uri = Uri.parse(url) + val uri = url.toUri() val intent = Intent(Intent.ACTION_VIEW) intent.data = uri intent.addCategory(Intent.CATEGORY_BROWSABLE) @@ -686,6 +606,9 @@ class HomeScreenActivity : AppCompatActivity(R.layout.activity_home_screen) { } catch (e: IllegalArgumentException) { Logger.w(LOG_TAG_DOWNLOAD, "Unregister receiver exception") } + // mark that app is going to background + appInBackground = true + Logger.v(LOG_TAG_UI, "home screen activity is stopped, app going to background") } private fun setupNavigationItemSelectedListener() { diff --git a/app/src/full/java/com/celzero/bravedns/ui/LauncherSwitcher.kt b/app/src/full/java/com/celzero/bravedns/ui/LauncherSwitcher.kt new file mode 100644 index 000000000..677b69df8 --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/ui/LauncherSwitcher.kt @@ -0,0 +1,82 @@ +/* + * Copyright 2025 RethinkDNS and its authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.celzero.bravedns.ui + +import Logger +import Logger.LOG_TAG_UI +import android.content.ComponentName +import android.content.Context +import android.content.pm.PackageManager +import android.os.Handler +import android.os.Looper.getMainLooper +import android.util.Log + +object LauncherSwitcher { + + private const val TAG = "LauncherSwitcher" + private const val DELAY_MILLIS = 1500L // delay in milliseconds for switching aliases + + /** + * Switches the launcher entry from one activity alias to another. + * + * @param context Application context + * @param toAlias Fully qualified name of the launcher alias to enable + * @param fromAlias Fully qualified name of the current launcher alias to disable + */ + fun switchLauncherAlias(context: Context, toAlias: String, fromAlias: String) { + val pm = context.packageManager + + Logger.v(LOG_TAG_UI, "$TAG switching launcher alias from $fromAlias to $toAlias") + if (toAlias == fromAlias) { + Log.w(LOG_TAG_UI, "$TAG both aliases are the same. No switch needed.") + return + } + + try { + Logger.i(LOG_TAG_UI, "$TAG disabling alias: $fromAlias, enabling alias: $toAlias") + // temporarily enable the new one before disabling the old one + pm.setComponentEnabledSetting( + ComponentName(context.packageName, toAlias), + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, + PackageManager.DONT_KILL_APP + ) + + // delay before disabling the old one; this is to ensure the new alias is registered + Handler(getMainLooper()).postDelayed({ + pm.setComponentEnabledSetting( + ComponentName(context.packageName, fromAlias), + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + PackageManager.DONT_KILL_APP + ) + }, DELAY_MILLIS) + Logger.i(LOG_TAG_UI, "$TAG launcher switched from $fromAlias to $toAlias") + } catch (e: Exception) { + Logger.e(LOG_TAG_UI, "$TAG err to switch launcher alias", e) + } + Logger.i(LOG_TAG_UI, "$TAG launcher alias switch completed: $fromAlias -> $toAlias") + } + + /** + * Checks if a given alias is currently enabled in the launcher. + */ + fun isAliasEnabled(context: Context, aliasName: String): Boolean { + val pm = context.packageManager + val state = pm.getComponentEnabledSetting(ComponentName(context.packageName, aliasName)) + val isEnabled = state == PackageManager.COMPONENT_ENABLED_STATE_ENABLED + Logger.i(LOG_TAG_UI, "$TAG alias $aliasName enabled state: $state, isEnabled: $isEnabled") + return isEnabled + } +} diff --git a/app/src/full/java/com/celzero/bravedns/ui/NotificationHandlerDialog.kt b/app/src/full/java/com/celzero/bravedns/ui/NotificationHandlerActivity.kt similarity index 71% rename from app/src/full/java/com/celzero/bravedns/ui/NotificationHandlerDialog.kt rename to app/src/full/java/com/celzero/bravedns/ui/NotificationHandlerActivity.kt index 5c370981d..3d4010e72 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/NotificationHandlerDialog.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/NotificationHandlerActivity.kt @@ -24,24 +24,35 @@ import android.os.Bundle import android.provider.Settings import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat +import androidx.core.view.WindowInsetsControllerCompat import com.celzero.bravedns.R import com.celzero.bravedns.service.VpnController +import com.celzero.bravedns.ui.activity.AppInfoActivity +import com.celzero.bravedns.ui.activity.AppInfoActivity.Companion.INTENT_UID import com.celzero.bravedns.ui.activity.AppListActivity +import com.celzero.bravedns.ui.activity.AppLockActivity import com.celzero.bravedns.ui.activity.PauseActivity import com.celzero.bravedns.util.Constants +import com.celzero.bravedns.util.Utilities.isAtleastQ import com.google.android.material.dialog.MaterialAlertDialogBuilder -class NotificationHandlerDialog : AppCompatActivity() { +class NotificationHandlerActivity : AppCompatActivity() { enum class TrampolineType { ACCESSIBILITY_SERVICE_FAILURE_DIALOG, - NEW_APP_INSTAL_DIALOG, - HOMESCREEN_ACTIVITY, + NEW_APP_INSTALL_DIALOG, + HOME_SCREEN_ACTIVITY, PAUSE_ACTIVITY, NONE } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } handleNotificationIntent(intent) } @@ -55,12 +66,12 @@ class NotificationHandlerDialog : AppCompatActivity() { private fun handleNotificationIntent(intent: Intent) { // app not started launch home screen if (!VpnController.isOn()) { - trampoline(TrampolineType.NONE) + trampoline(TrampolineType.NONE, intent) return } if (VpnController.isAppPaused()) { - trampoline(TrampolineType.PAUSE_ACTIVITY) + trampoline(TrampolineType.PAUSE_ACTIVITY, intent) return } @@ -68,28 +79,28 @@ class NotificationHandlerDialog : AppCompatActivity() { if (isAccessibilityIntent(intent)) { TrampolineType.ACCESSIBILITY_SERVICE_FAILURE_DIALOG } else if (isNewAppInstalledIntent(intent)) { - TrampolineType.NEW_APP_INSTAL_DIALOG + TrampolineType.NEW_APP_INSTALL_DIALOG } else { TrampolineType.NONE } - trampoline(t) + trampoline(t, intent) } - private fun trampoline(trampolineType: TrampolineType) { + private fun trampoline(trampolineType: TrampolineType, intent: Intent) { Logger.i(LOG_TAG_VPN, "act on notification, notification type: $trampolineType") when (trampolineType) { TrampolineType.ACCESSIBILITY_SERVICE_FAILURE_DIALOG -> { handleAccessibilitySettings() } - TrampolineType.NEW_APP_INSTAL_DIALOG -> { + TrampolineType.NEW_APP_INSTALL_DIALOG -> { // navigate to all apps screen - launchFirewallActivityAndFinish() + launchFirewallActivityAndFinish(intent) } - TrampolineType.HOMESCREEN_ACTIVITY -> { + TrampolineType.HOME_SCREEN_ACTIVITY -> { launchHomeScreenAndFinish() } TrampolineType.PAUSE_ACTIVITY -> { - showAppPauseDialog(trampolineType) + showAppPauseDialog(trampolineType, intent) } TrampolineType.NONE -> { launchHomeScreenAndFinish() @@ -98,15 +109,27 @@ class NotificationHandlerDialog : AppCompatActivity() { } private fun launchHomeScreenAndFinish() { - startActivity(Intent(this, HomeScreenActivity::class.java)) + // handle the app lock state then launch home screen + val intent = Intent(this, AppLockActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + startActivity(intent) finish() } - private fun launchFirewallActivityAndFinish() { - val intent = Intent(this, AppListActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED - startActivity(intent) - finish() + private fun launchFirewallActivityAndFinish(recvIntent: Intent) { + // TODO: handle the app lock state then launch firewall activity + val uid = recvIntent.getIntExtra(Constants.NOTIF_INTENT_EXTRA_APP_UID, Int.MIN_VALUE) + Logger.d(LOG_TAG_VPN, "notification intent - new app installed, uid: $uid") + if (uid > 0) { + val intent = Intent(this, AppInfoActivity::class.java) + intent.putExtra(INTENT_UID, uid) + startActivity(intent) + finish() + } else { + val intent = Intent(this, AppListActivity::class.java) + startActivity(intent) + finish() + } } private fun handleAccessibilitySettings() { @@ -131,7 +154,7 @@ class NotificationHandlerDialog : AppCompatActivity() { ContextCompat.startActivity(context, intent, null) } - private fun showAppPauseDialog(trampolineType: TrampolineType) { + private fun showAppPauseDialog(trampolineType: TrampolineType, intent: Intent) { val builder = MaterialAlertDialogBuilder(this) builder.setTitle(R.string.notif_dialog_pause_dialog_title) @@ -141,7 +164,7 @@ class NotificationHandlerDialog : AppCompatActivity() { builder.setPositiveButton(R.string.notif_dialog_pause_dialog_positive) { _, _ -> VpnController.resumeApp() - trampoline(trampolineType) + trampoline(trampolineType, intent) } builder.setNegativeButton(R.string.notif_dialog_pause_dialog_negative) { _, _ -> finish() } diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/AdvancedSettingActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/AdvancedSettingActivity.kt new file mode 100644 index 000000000..6787fb90b --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/ui/activity/AdvancedSettingActivity.kt @@ -0,0 +1,412 @@ +/* + * Copyright 2024 RethinkDNS and its authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.celzero.bravedns.ui.activity + +import Logger +import Logger.LOG_TAG_UI +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.text.InputType +import android.view.Gravity +import android.view.View +import android.widget.CompoundButton +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.SeekBar +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.AppCompatTextView +import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.lifecycleScope +import by.kirich1409.viewbindingdelegate.viewBinding +import com.celzero.bravedns.R +import com.celzero.bravedns.databinding.ActivityAdvancedSettingBinding +import com.celzero.bravedns.scheduler.BugReportZipper.BUG_REPORT_DIR_NAME +import com.celzero.bravedns.scheduler.BugReportZipper.BUG_REPORT_ZIP_FILE_NAME +import com.celzero.bravedns.scheduler.EnhancedBugReport.TOMBSTONE_DIR_NAME +import com.celzero.bravedns.scheduler.EnhancedBugReport.TOMBSTONE_ZIP_FILE_NAME +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.service.WireguardManager +import com.celzero.bravedns.util.Constants.Companion.LOCAL_BLOCKLIST_DOWNLOAD_FOLDER_NAME +import com.celzero.bravedns.util.Constants.Companion.REMOTE_BLOCKLIST_DOWNLOAD_FOLDER_NAME +import com.celzero.bravedns.util.Themes +import com.celzero.bravedns.util.Utilities.blocklistCanonicalPath +import com.celzero.bravedns.util.Utilities.deleteRecursive +import com.celzero.bravedns.util.Utilities.isAtleastQ +import com.celzero.bravedns.util.Utilities.isAtleastS +import com.celzero.bravedns.util.Utilities.isPlayStoreFlavour +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject +import java.io.File + +class AdvancedSettingActivity : AppCompatActivity(R.layout.activity_advanced_setting) { + private val persistentState by inject() + private val b by viewBinding(ActivityAdvancedSettingBinding::bind) + + // Handler to update the dialer timeout value when the seekbar is moved + private val handler = Handler(Looper.getMainLooper()) + private var updateRunnable: Runnable? = null + + companion object { + private const val TAG = "AdvSetAct" + private const val ONE_SEC = 1000L + } + + private fun Context.isDarkThemeOn(): Boolean { + return resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == + Configuration.UI_MODE_NIGHT_YES + } + + override fun onCreate(savedInstanceState: Bundle?) { + setTheme(Themes.getCurrentTheme(isDarkThemeOn(), persistentState.theme)) + super.onCreate(savedInstanceState) + + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + initView() + setupClickListeners() + } + + private fun initView() { + b.dvWgListenPortSwitch.isChecked = !persistentState.randomizeListenPort + // Auto start app after reboot + b.settingsActivityAutoStartSwitch.isChecked = persistentState.prefAutoStartBootUp + // check if the device is running on Android 12 or above for EIMF + if (isAtleastS()) { + // endpoint independent mapping (eim) / endpoint independent filtering (eif) + b.dvEimfRl.visibility = View.VISIBLE + b.dvEimfSwitch.isChecked = persistentState.endpointIndependence + } else { + b.dvEimfRl.visibility = View.GONE + } + + b.dvTcpKeepAliveSwitch.isChecked = persistentState.tcpKeepAlive + b.settingsActivitySlowdownSwitch.isChecked = persistentState.slowdownMode + + b.dvExperimentalSwitch.isChecked = persistentState.nwEngExperimentalFeatures + b.dvAutoDialSwitch.isChecked = persistentState.autoDialsParallel + b.dvIpInfoSwitch.isChecked = persistentState.downloadIpInfo + updateDialerTimeOutUi() + } + + private fun updateDialerTimeOutUi() { + val valueMin = persistentState.dialTimeoutSec / 60 + Logger.d(LOG_TAG_UI, "$TAG; dialer timeout value: $valueMin, persistentState: ${persistentState.dialTimeoutSec}") + val displayText = if (valueMin == 0) { + getString(R.string.dialer_timeout_desc, getString(R.string.lbl_disabled)) + } else { + getString(R.string.dialer_timeout_desc, "$valueMin ${getString(R.string.lbl_min)}") + } + b.dvTimeoutDesc.text = displayText + Logger.d(LOG_TAG_UI, "$TAG; dialer timeout value: $valueMin, progress: ${b.dvTimeoutSeekbar.progress}") + if (valueMin == b.dvTimeoutSeekbar.progress) return + b.dvTimeoutSeekbar.progress = valueMin + } + + private fun updateDialerTimeOut(valueMin: Int) { + persistentState.dialTimeoutSec = valueMin * 60 + updateDialerTimeOutUi() + } + + private fun setupClickListeners() { + + b.dvWgListenPortSwitch.setOnCheckedChangeListener { _, isChecked -> + persistentState.randomizeListenPort = !isChecked + } + + b.dvWgListenPortRl.setOnClickListener { + b.dvWgListenPortSwitch.isChecked = !b.dvWgListenPortSwitch.isChecked + } + + b.dvEimfSwitch.setOnCheckedChangeListener { _, isChecked -> + if (!isAtleastS()) { + return@setOnCheckedChangeListener + } + + persistentState.endpointIndependence = isChecked + } + + b.dvEimfRl.setOnClickListener { b.dvEimfSwitch.isChecked = !b.dvEimfSwitch.isChecked } + + b.settingsAntiCensorshipRl.setOnClickListener { + val intent = Intent(this, AntiCensorshipActivity::class.java) + startActivity(intent) + } + + b.settingsConsoleLogRl.setOnClickListener { openConsoleLogActivity() } + + b.settingsActivityAutoStartRl.setOnClickListener { + b.settingsActivityAutoStartSwitch.isChecked = + !b.settingsActivityAutoStartSwitch.isChecked + } + + b.settingsActivityAutoStartSwitch.setOnCheckedChangeListener { _: CompoundButton, b: Boolean + -> + persistentState.prefAutoStartBootUp = b + } + + b.dvTcpKeepAliveSwitch.setOnCheckedChangeListener { _, isChecked -> + persistentState.tcpKeepAlive = isChecked + } + + b.dvTcpKeepAliveRl.setOnClickListener { b.dvTcpKeepAliveSwitch.isChecked = !b.dvTcpKeepAliveSwitch.isChecked } + + b.settingsActivitySlowdownRl.setOnClickListener { + b.settingsActivitySlowdownSwitch.isChecked = !b.settingsActivitySlowdownSwitch.isChecked + } + + b.settingsActivitySlowdownSwitch.setOnCheckedChangeListener { _, isChecked -> + persistentState.slowdownMode = isChecked + } + + b.dvExperimentalSwitch.setOnCheckedChangeListener { _, isChecked -> + persistentState.nwEngExperimentalFeatures = isChecked + } + + b.settingsClearResidueRl.setOnClickListener { + clearResidueAfterConfirmation() + } + + b.settingsAutoDialRl.setOnClickListener { + b.dvAutoDialSwitch.isChecked = !b.dvAutoDialSwitch.isChecked + } + + b.dvAutoDialSwitch.setOnCheckedChangeListener { _, isChecked -> + persistentState.autoDialsParallel = isChecked + } + + b.settingsIpInfoRl.setOnClickListener { + b.dvIpInfoSwitch.isChecked = !b.dvIpInfoSwitch.isChecked + } + + b.dvIpInfoSwitch.setOnCheckedChangeListener { _, isChecked -> + persistentState.downloadIpInfo = isChecked + } + + b.settingsTaskerRl.setOnClickListener { + showAppTriggerPackageDialog(this , onPackageSet = { packageName -> + persistentState.appTriggerPackages = packageName + }) + } + + b.dvTimeoutSeekbar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + handler.removeCallbacks(updateRunnable ?: Runnable {}) + + updateRunnable = Runnable { + updateDialerTimeOut(progress) + } + + handler.postDelayed(updateRunnable!!, ONE_SEC) + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) { + handler.removeCallbacks(updateRunnable ?: Runnable {}) + } + + override fun onStopTrackingTouch(seekBar: SeekBar?) { + updateRunnable?.let { + handler.removeCallbacks(it) + handler.post(it) + } + } + }) + } + + private fun clearResidueAfterConfirmation() { + val alertBuilder = MaterialAlertDialogBuilder(this) + alertBuilder.setTitle(getString(R.string.clear_residue_dialog_heading)) + alertBuilder.setMessage(getString(R.string.clear_residue_dialog_desc)) + alertBuilder.setCancelable(false) + alertBuilder.setPositiveButton(getString(R.string.lbl_proceed)) { dialog, _ -> + dialog.dismiss() + clearResidue() + } + alertBuilder.setNegativeButton(getString(R.string.lbl_cancel)) { dialog, _ -> + dialog.dismiss() + } + alertBuilder.create().show() + } + + private fun clearResidue() { + // when app is in play store version delete the local blocklists + if (isPlayStoreFlavour()) { + // delete the local blocklists + deleteLocalBlocklists() + } + deleteUnusedBlocklists() + deleteLogs() + io { WireguardManager.deleteResidueWgs() } + } + + private fun deleteLocalBlocklists() { + // in play version so delete the local blocklists + val path = blocklistCanonicalPath(this, LOCAL_BLOCKLIST_DOWNLOAD_FOLDER_NAME) + val dir = File(path) + val res = deleteRecursive(dir) + // reset the local blocklists + if (res) { + persistentState.localBlocklistStamp = "" + persistentState.localBlocklistTimestamp = 0L + } + Logger.i(LOG_TAG_UI, "$TAG; local blocklists deleted, path: $path") + } + + private fun deleteUnusedBlocklists() { + Logger.v(LOG_TAG_UI, "$TAG; deleting unused blocklists") + // delete all the blocklists other than the one in the settings + val localTsDir = persistentState.localBlocklistTimestamp + val remoteTsDir = persistentState.remoteBlocklistTimestamp + val localBlocklistDir = File(blocklistCanonicalPath(this, LOCAL_BLOCKLIST_DOWNLOAD_FOLDER_NAME)) + val remoteBlocklistDir = File(blocklistCanonicalPath(this, REMOTE_BLOCKLIST_DOWNLOAD_FOLDER_NAME)) + // all the blocklists are named with their timestamp as directory name + if (localBlocklistDir.exists() && localBlocklistDir.isDirectory) { + localBlocklistDir.listFiles()?.forEach { file -> + if (file.isDirectory && file.name.toLongOrNull() != localTsDir) { + if (deleteRecursive(file)) { + Logger.i(LOG_TAG_UI, "$TAG; deleted unused local blocklist: ${file.name}") + } else { + Logger.w(LOG_TAG_UI, "$TAG; failed to delete unused local blocklist: ${file.name}") + } + } + } + } + if (remoteBlocklistDir.exists() && remoteBlocklistDir.isDirectory) { + remoteBlocklistDir.listFiles()?.forEach { file -> + if (file.isDirectory && file.name.toLongOrNull() != remoteTsDir) { + if (deleteRecursive(file)) { + Logger.i(LOG_TAG_UI, "$TAG; deleted unused remote blocklist: ${file.name}") + } else { + Logger.w(LOG_TAG_UI, "$TAG; failed to delete unused remote blocklist: ${file.name}") + } + } + } + } + } + + private fun deleteLogs(): Boolean { + Logger.v(LOG_TAG_UI, "$TAG; deleting all log files before backup") + try { + // delete tombstone logs + val tombstoneDir = File(filesDir.canonicalPath + File.separator + TOMBSTONE_DIR_NAME) + if (tombstoneDir.exists() && tombstoneDir.isDirectory) { + tombstoneDir.listFiles()?.forEach { file -> + if (file.delete()) { + Logger.i(LOG_TAG_UI, "$TAG; deleted log file: ${file.name}") + } else { + Logger.w(LOG_TAG_UI, "$TAG; failed to delete log file: ${file.name}") + } + } + } + + // delete bugreport logs + val bugreportDir = File(filesDir.canonicalPath + File.separator + BUG_REPORT_DIR_NAME) + if (bugreportDir.exists() && bugreportDir.isDirectory) { + deleteRecursive(bugreportDir) + Logger.i(LOG_TAG_UI, "$TAG; deleted bugreport logs from: ${bugreportDir.canonicalPath}") + } + + // delete zip files + val tombstoneZip = File(filesDir.canonicalPath + File.separator + TOMBSTONE_ZIP_FILE_NAME) + if (tombstoneZip.exists()) { + tombstoneZip.delete() + Logger.i(LOG_TAG_UI, "$TAG; deleted tombstone zip file: ${tombstoneZip.canonicalPath}") + } + + val bugreportZip = File(filesDir.canonicalPath + File.separator + BUG_REPORT_ZIP_FILE_NAME) + if (bugreportZip.exists()) { + bugreportZip.delete() + Logger.i(LOG_TAG_UI, "$TAG; deleted bugreport zip file: ${bugreportZip.canonicalPath}") + } + + Logger.i(LOG_TAG_UI, "$TAG; deleted all log files successfully") + return true + } catch (e: Exception) { + Logger.w(LOG_TAG_UI, "$TAG; err while deleting log files: ${e.message}") + return false + } + } + + private fun openConsoleLogActivity() { + try { + val intent = Intent(this, ConsoleLogActivity::class.java) + startActivity(intent) + } catch (e: Exception) { + Logger.e(LOG_TAG_UI, "$TAG; err opening console log activity ${e.message}", e) + } + } + + fun showAppTriggerPackageDialog(context: Context, onPackageSet: (String) -> Unit) { + val editText = EditText(context).apply { + hint = context.getString(R.string.adv_tasker_dialog_edit_hint) + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE + setLines(6) + setHorizontallyScrolling(false) + if (persistentState.appTriggerPackages.isNotEmpty()) { + setText(persistentState.appTriggerPackages) + } + gravity = Gravity.TOP or Gravity.START + } + + val selectableTextView = AppCompatTextView(context).apply { + text = context.getString(R.string.adv_tasker_dialog_msg) + setTextIsSelectable(true) + setPadding(50, 40, 50, 0) + textSize = 16f + } + + // add a LinearLayout as the single child of the ScrollView, then add the text view and + // edit text to the LinearLayout. + val linearLayout = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + addView(selectableTextView) + addView(editText) + } + + val scrollView = ScrollView(context).apply { + setPadding(40, 10, 40, 0) + addView(linearLayout) + } + + AlertDialog.Builder(context) + .setTitle(context.getString(R.string.adv_tasker_dialog_title)) + .setView(scrollView) + .setPositiveButton(context.getString(R.string.lbl_save)) { dialog, _ -> + val pkgName = editText.text.toString().trim() + if (pkgName.isNotEmpty()) { + onPackageSet(pkgName) + } + dialog.dismiss() + } + .setNegativeButton(context.getString(R.string.lbl_cancel)) { dialog, _ -> dialog.cancel() } + .show() + } + + + private fun io(f: suspend () -> Unit) { + lifecycleScope.launch(Dispatchers.IO) { f() } + } +} diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/AntiCensorshipActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/AntiCensorshipActivity.kt new file mode 100644 index 000000000..3c8b495b2 --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/ui/activity/AntiCensorshipActivity.kt @@ -0,0 +1,279 @@ +/* + * Copyright 2024 RethinkDNS and its authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.celzero.bravedns.ui.activity + +import Logger +import Logger.LOG_TAG_FIREWALL +import android.content.Context +import android.content.res.Configuration +import android.os.Bundle +import android.view.View +import android.widget.CompoundButton +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowInsetsControllerCompat +import by.kirich1409.viewbindingdelegate.viewBinding +import com.celzero.bravedns.R +import com.celzero.bravedns.databinding.ActivityAntiCensorshipBinding +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.util.Themes +import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.isAtleastQ +import com.celzero.bravedns.util.Utilities.isOsVersionAbove412 +import org.koin.android.ext.android.inject +import com.celzero.firestack.settings.Settings + +class AntiCensorshipActivity : AppCompatActivity(R.layout.activity_anti_censorship) { + val b by viewBinding(ActivityAntiCensorshipBinding::bind) + + private val persistentState by inject() + + private fun Context.isDarkThemeOn(): Boolean { + return resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == + Configuration.UI_MODE_NIGHT_YES + } + + companion object { + private const val DESYNC_SUPPORTED_VERSION = "4.12" + } + + enum class DialStrategies(val mode: Int) { + SPLIT_AUTO(Settings.SplitAuto), + SPLIT_TCP(Settings.SplitTCP), + SPLIT_TCP_TLS(Settings.SplitTCPOrTLS), + DESYNC(Settings.SplitDesync), + NEVER_SPLIT(Settings.SplitNever) + } + + enum class RetryStrategies(val mode: Int) { + RETRY_WITH_SPLIT(Settings.RetryWithSplit), + RETRY_NEVER(Settings.RetryNever), + RETRY_AFTER_SPLIT(Settings.RetryAfterSplit) + } + + override fun onCreate(savedInstanceState: Bundle?) { + setTheme(Themes.getCurrentTheme(isDarkThemeOn(), persistentState.theme)) + super.onCreate(savedInstanceState) + + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + initView() + setupClickListeners() + } + + private fun initView() { + updateDialStrategy(persistentState.dialStrategy) + updateRetryStrategy(persistentState.retryStrategy) + } + + private fun updateDialStrategy(selectedState: Int) { + if (!isOsVersionAbove412(DESYNC_SUPPORTED_VERSION)) { + // desync is not supported for os version below 4.12 + // so reset the dial strategy to split auto if desync is selected + if (selectedState == DialStrategies.DESYNC.mode) { + persistentState.dialStrategy = DialStrategies.SPLIT_AUTO.mode + b.acRadioDesync.isChecked = false + b.acDesyncRl.visibility = View.GONE + b.acRadioSplitAuto.isChecked = true + Logger.i(LOG_TAG_FIREWALL, "Desync mode is not supported in Android 11 and below") + return + } else { + b.acRadioDesync.isEnabled = false + b.acDesyncRl.visibility = View.GONE + } + } + when (selectedState) { + DialStrategies.NEVER_SPLIT.mode -> { + b.acRadioNeverSplit.isChecked = true + } + DialStrategies.SPLIT_AUTO.mode -> { + b.acRadioSplitAuto.isChecked = true + } + DialStrategies.SPLIT_TCP.mode -> { + b.acRadioSplitTcp.isChecked = true + } + DialStrategies.SPLIT_TCP_TLS.mode -> { + b.acRadioSplitTls.isChecked = true + } + DialStrategies.DESYNC.mode -> { + b.acRadioDesync.isChecked = true + } + } + } + + private fun updateRetryStrategy(selectedState: Int) { + when (selectedState) { + RetryStrategies.RETRY_WITH_SPLIT.mode -> { + b.acRadioRetryWithSplit.isChecked = true + } + RetryStrategies.RETRY_NEVER.mode -> { + b.acRadioNeverRetry.isChecked = true + } + RetryStrategies.RETRY_AFTER_SPLIT.mode -> { + b.acRadioRetryAfterSplit.isChecked = true + } + } + } + + private fun setupClickListeners() { + b.acRadioNeverSplit.setOnCheckedChangeListener { _: CompoundButton, isSelected: Boolean -> + handleAcMode(isSelected, DialStrategies.NEVER_SPLIT.mode) + } + + b.acNeverSplitRl.setOnClickListener { + b.acRadioNeverSplit.isChecked = !b.acRadioNeverSplit.isChecked + } + + b.acRadioSplitAuto.setOnCheckedChangeListener { _: CompoundButton, isSelected: Boolean -> + handleAcMode(isSelected, DialStrategies.SPLIT_AUTO.mode) + } + + b.acRadioSplitTcp.setOnCheckedChangeListener { _: CompoundButton, isSelected: Boolean -> + handleAcMode(isSelected, DialStrategies.SPLIT_TCP.mode) + } + + b.acRadioSplitTls.setOnCheckedChangeListener { _: CompoundButton, isSelected: Boolean -> + handleAcMode(isSelected, DialStrategies.SPLIT_TCP_TLS.mode) + } + + b.acRadioDesync.setOnCheckedChangeListener { _: CompoundButton, isSelected: Boolean -> + handleAcMode(isSelected, DialStrategies.DESYNC.mode) + } + + b.acSplitAutoRl.setOnClickListener { + b.acRadioSplitAuto.isChecked = !b.acRadioSplitAuto.isChecked + } + + b.acSplitTcpRl.setOnClickListener { + b.acRadioSplitTcp.isChecked = !b.acRadioSplitTcp.isChecked + } + + b.acSplitTlsRl.setOnClickListener { + b.acRadioSplitTls.isChecked = !b.acRadioSplitTls.isChecked + } + + b.acDesyncRl.setOnClickListener { + b.acRadioDesync.isChecked = !b.acRadioDesync.isChecked + } + + b.acRadioRetryWithSplit.setOnCheckedChangeListener { _: CompoundButton, isSelected: Boolean -> + handleRetryMode(isSelected, RetryStrategies.RETRY_WITH_SPLIT.mode) + } + + b.acRadioNeverRetry.setOnCheckedChangeListener { _: CompoundButton, isSelected: Boolean -> + handleRetryMode(isSelected, RetryStrategies.RETRY_NEVER.mode) + } + + b.acRadioRetryAfterSplit.setOnCheckedChangeListener { _: CompoundButton, isSelected: Boolean -> + handleRetryMode(isSelected, RetryStrategies.RETRY_AFTER_SPLIT.mode) + } + + b.acRetryWithSplitRl.setOnClickListener { + b.acRadioRetryWithSplit.isChecked = !b.acRadioRetryWithSplit.isChecked + } + + b.acRetryNeverRl.setOnClickListener { + b.acRadioNeverRetry.isChecked = !b.acRadioNeverRetry.isChecked + } + + b.acRetryAfterSplitRl.setOnClickListener { + b.acRadioRetryAfterSplit.isChecked = !b.acRadioRetryAfterSplit.isChecked + } + } + + private fun handleAcMode(isSelected: Boolean, mode: Int) { + if (isSelected) { + persistentState.dialStrategy = mode + disableRadioButtons(mode) + if (mode == DialStrategies.NEVER_SPLIT.mode) { + // disable retry radio buttons for never split + handleRetryMode(true, RetryStrategies.RETRY_NEVER.mode) + } + } else { + // no-op + } + } + + private fun handleRetryMode(isSelected: Boolean, mode: Int) { + var m = mode + if (DialStrategies.NEVER_SPLIT.mode == persistentState.dialStrategy) { + m = RetryStrategies.RETRY_NEVER.mode + Utilities.showToastUiCentered(this, getString(R.string.ac_toast_retry_disabled), Toast.LENGTH_LONG) + } + + if (isSelected) { + persistentState.retryStrategy = m + disableRetryRadioButtons(m) + } else { + // no-op + } + } + + private fun disableRadioButtons(mode: Int) { + when (mode) { + DialStrategies.NEVER_SPLIT.mode -> { + b.acRadioSplitAuto.isChecked = false + b.acRadioSplitTcp.isChecked = false + b.acRadioSplitTls.isChecked = false + b.acRadioDesync.isChecked = false + } + DialStrategies.SPLIT_AUTO.mode -> { + b.acRadioNeverSplit.isChecked = false + b.acRadioSplitTcp.isChecked = false + b.acRadioSplitTls.isChecked = false + b.acRadioDesync.isChecked = false + } + DialStrategies.SPLIT_TCP.mode -> { + b.acRadioNeverSplit.isChecked = false + b.acRadioSplitAuto.isChecked = false + b.acRadioSplitTls.isChecked = false + b.acRadioDesync.isChecked = false + } + DialStrategies.SPLIT_TCP_TLS.mode -> { + b.acRadioNeverSplit.isChecked = false + b.acRadioSplitAuto.isChecked = false + b.acRadioSplitTcp.isChecked = false + b.acRadioDesync.isChecked = false + } + DialStrategies.DESYNC.mode -> { + b.acRadioNeverSplit.isChecked = false + b.acRadioSplitAuto.isChecked = false + b.acRadioSplitTcp.isChecked = false + b.acRadioSplitTls.isChecked = false + } + } + } + + private fun disableRetryRadioButtons(mode: Int) { + when (mode) { + RetryStrategies.RETRY_WITH_SPLIT.mode -> { + b.acRadioNeverRetry.isChecked = false + b.acRadioRetryAfterSplit.isChecked = false + } + RetryStrategies.RETRY_NEVER.mode -> { + b.acRadioRetryWithSplit.isChecked = false + b.acRadioRetryAfterSplit.isChecked = false + } + RetryStrategies.RETRY_AFTER_SPLIT.mode -> { + b.acRadioRetryWithSplit.isChecked = false + b.acRadioNeverRetry.isChecked = false + } + } + } +} diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/AppInfoActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/AppInfoActivity.kt index f57a03d34..85de65f53 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/activity/AppInfoActivity.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/activity/AppInfoActivity.kt @@ -15,6 +15,8 @@ */ package com.celzero.bravedns.ui.activity +import Logger +import Logger.LOG_TAG_UI import android.content.Context import android.content.DialogInterface import android.content.Intent @@ -30,6 +32,7 @@ import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.TooltipCompat import androidx.core.content.ContextCompat +import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import by.kirich1409.viewbindingdelegate.viewBinding @@ -38,11 +41,12 @@ import com.celzero.bravedns.R import com.celzero.bravedns.adapter.AppWiseDomainsAdapter import com.celzero.bravedns.adapter.AppWiseIpsAdapter import com.celzero.bravedns.database.AppInfo -import com.celzero.bravedns.database.ConnectionTrackerRepository import com.celzero.bravedns.databinding.ActivityAppDetailsBinding import com.celzero.bravedns.service.FirewallManager import com.celzero.bravedns.service.FirewallManager.updateFirewallStatus import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.service.ProxyManager +import com.celzero.bravedns.service.ProxyManager.ID_NONE import com.celzero.bravedns.service.VpnController import com.celzero.bravedns.util.Constants import com.celzero.bravedns.util.Constants.Companion.INVALID_UID @@ -51,6 +55,7 @@ import com.celzero.bravedns.util.Themes import com.celzero.bravedns.util.UIUtils.openAndroidAppInfo import com.celzero.bravedns.util.UIUtils.updateHtmlEncodedText import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.isAtleastQ import com.celzero.bravedns.util.Utilities.showToastUiCentered import com.celzero.bravedns.viewmodel.AppConnectionsViewModel import com.celzero.bravedns.viewmodel.CustomDomainViewModel @@ -80,14 +85,27 @@ class AppInfoActivity : AppCompatActivity(R.layout.activity_app_details) { private var showBypassToolTip: Boolean = true + private var isRethinkApp: Boolean = false + companion object { - const val UID_INTENT_NAME = "UID" + const val INTENT_UID = "UID" + const val INTENT_ACTIVE_CONNS = "ACTIVE_CONNS" + const val INTENT_ASN = "ASN" + private const val TAG = "AppInfoActivity" } override fun onCreate(savedInstanceState: Bundle?) { setTheme(Themes.getCurrentTheme(isDarkThemeOn(), persistentState.theme)) super.onCreate(savedInstanceState) - uid = intent.getIntExtra(UID_INTENT_NAME, INVALID_UID) + + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + + uid = intent.getIntExtra(INTENT_UID, INVALID_UID) + Logger.d(LOG_TAG_UI, "AppInfoActivity, intent uid: $uid") ipRulesViewModel.setUid(uid) domainRulesViewModel.setUid(uid) networkLogsViewModel.setUid(uid) @@ -121,8 +139,10 @@ class AppInfoActivity : AppCompatActivity(R.layout.activity_app_details) { this.appInfo = appInfo b.aadAppDetailName.text = appName(packages.count()) + b.aadPkgName.text = appInfo.packageName b.excludeProxySwitch.isChecked = appInfo.isProxyExcluded - updateDataUsage() + displayDataUsage() + displayProxyStatus() displayIcon( Utilities.getIcon(this, appInfo.packageName, appInfo.appName), b.aadAppDetailIcon @@ -130,23 +150,54 @@ class AppInfoActivity : AppCompatActivity(R.layout.activity_app_details) { // do not show the firewall status if the app is Rethink if (appInfo.packageName == rethinkPkgName) { + isRethinkApp = true b.aadFirewallStatus.visibility = View.GONE hideFirewallStatusUi() hideDomainBlockUi() hideIpBlockUi() - return@uiCtx + hideBypassProxyUi() + setRethinkDomainLogsAdapter() + setRethinkIpLogsAdapter() + } else { + updateFirewallStatusUi(appStatus, connStatus) + setActiveConnsAdapter() + if (persistentState.downloadIpInfo) { + setASNAdapter() + } + setDomainsAdapter() + setIpAdapter() + } + + // disable exclude app option for apps with no package name + if (FirewallManager.isUnknownPackage(uid)) { + b.aadAppSettingsExclude.alpha = 0.5f + b.aadAppSettingsExclude.isEnabled = false + } else { + b.aadAppSettingsExclude.alpha = 1.0f + b.aadAppSettingsExclude.isEnabled = true } - updateFirewallStatusUi(appStatus, connStatus) - setDomainsAdapter() - setIpAdapter() } } } + private fun displayProxyStatus() { + val proxy = ProxyManager.getProxyIdForApp(appInfo.uid) + if (proxy.isEmpty() || proxy == ID_NONE) { + b.aadProxyDetails.visibility = View.GONE + return + } + b.aadProxyDetails.visibility = View.VISIBLE + b.aadProxyDetails.text = getString(R.string.wireguard_apps_proxy_map_desc, proxy) + } + private fun hideFirewallStatusUi() { b.aadAppSettingsCard.visibility = View.GONE } + private fun hideBypassProxyUi() { + b.excludeProxyRl.visibility = View.GONE + } + private fun hideDomainBlockUi() { b.aadDomainBlockCard.visibility = View.GONE } @@ -157,10 +208,6 @@ class AppInfoActivity : AppCompatActivity(R.layout.activity_app_details) { private fun openCustomIpScreen() { val intent = Intent(this, CustomRulesActivity::class.java) - // this activity is either being started in a new task or bringing to the top an - // existing task, then it will be launched as the front door of the task. - // This will result in the application to have that task in the proper state. - intent.flags = Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED intent.putExtra(VIEW_PAGER_SCREEN_TO_LOAD, CustomRulesActivity.Tabs.IP_RULES.screen) intent.putExtra(Constants.INTENT_UID, uid) startActivity(intent) @@ -168,13 +215,12 @@ class AppInfoActivity : AppCompatActivity(R.layout.activity_app_details) { private fun openCustomDomainScreen() { val intent = Intent(this, CustomRulesActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED intent.putExtra(VIEW_PAGER_SCREEN_TO_LOAD, CustomRulesActivity.Tabs.DOMAIN_RULES.screen) intent.putExtra(Constants.INTENT_UID, uid) startActivity(intent) } - private fun updateDataUsage() { + private fun displayDataUsage() { val u = Utilities.humanReadableByteCount(appInfo.uploadBytes, true) val uploadBytes = getString(R.string.symbol_upload, u) val d = Utilities.humanReadableByteCount(appInfo.downloadBytes, true) @@ -228,12 +274,12 @@ class AppInfoActivity : AppCompatActivity(R.layout.activity_app_details) { b.aadAppInfoIcon.setOnClickListener { io { - val packages = FirewallManager.getAppNamesByUid(appInfo.uid) + val appNames = FirewallManager.getAppNamesByUid(appInfo.uid) uiCtx { - if (packages.count() == 1) { + if (appNames.count() == 1) { openAndroidAppInfo(this, appInfo.packageName) - } else if (packages.count() > 1) { - showAppInfoDialog(packages) + } else if (appNames.count() > 1) { + showAppInfoDialog(appNames) } else { showToastUiCentered( this, @@ -253,6 +299,8 @@ class AppInfoActivity : AppCompatActivity(R.layout.activity_app_details) { ) ) + TooltipCompat.setTooltipText(b.aadCloseConnsChip, getString(R.string.close_conn_tool_tip)) + b.aadAppSettingsBypassDnsFirewall.setOnClickListener { // show the tooltip only once when app is not bypassed (dns + firewall) earlier if (showBypassToolTip && appStatus == FirewallManager.FirewallStatus.NONE) { @@ -305,17 +353,30 @@ class AppInfoActivity : AppCompatActivity(R.layout.activity_app_details) { return@setOnClickListener } - // change the status to allowed if already app is excluded - if (appStatus == FirewallManager.FirewallStatus.EXCLUDE) { - updateFirewallStatus( - FirewallManager.FirewallStatus.NONE, - FirewallManager.ConnectionStatus.ALLOW - ) - } else { - updateFirewallStatus( - FirewallManager.FirewallStatus.EXCLUDE, - FirewallManager.ConnectionStatus.ALLOW - ) + io { + if (FirewallManager.isUnknownPackage(uid) && appStatus == FirewallManager.FirewallStatus.EXCLUDE) { + uiCtx { + showToastUiCentered( + this, + getString(R.string.exclude_no_package_err_toast), + Toast.LENGTH_LONG + ) + } + return@io + } + + // change the status to allowed if already app is excluded + if (appStatus == FirewallManager.FirewallStatus.EXCLUDE) { + updateFirewallStatus( + FirewallManager.FirewallStatus.NONE, + FirewallManager.ConnectionStatus.ALLOW + ) + } else { + updateFirewallStatus( + FirewallManager.FirewallStatus.EXCLUDE, + FirewallManager.ConnectionStatus.ALLOW + ) + } } } @@ -342,6 +403,10 @@ class AppInfoActivity : AppCompatActivity(R.layout.activity_app_details) { b.aadDomainsChip.setOnClickListener { openAppWiseDomainLogsActivity() } + b.aadActiveConnsChip.setOnClickListener { openAppWiseDomainLogsActivity(activeConns = true) } + + b.aadAsnChip.setOnClickListener { openAppWiseIpLogsActivity(asn = true) } + b.excludeProxySwitch.setOnCheckedChangeListener { _, isChecked -> updateExcludeProxyStatus(isChecked) } @@ -349,6 +414,10 @@ class AppInfoActivity : AppCompatActivity(R.layout.activity_app_details) { b.excludeProxyRl.setOnClickListener { b.excludeProxySwitch.isChecked = !b.excludeProxySwitch.isChecked } + + b.aadCloseConnsChip.setOnClickListener { + showCloseConnectionDialog(uid, appInfo.appName) + } } private fun updateExcludeProxyStatus(isExcluded: Boolean) { @@ -357,22 +426,65 @@ class AppInfoActivity : AppCompatActivity(R.layout.activity_app_details) { } } - private fun openAppWiseDomainLogsActivity() { + private fun openAppWiseDomainLogsActivity(activeConns: Boolean = false) { val intent = Intent(this, AppWiseDomainLogsActivity::class.java) - intent.putExtra(UID_INTENT_NAME, uid) + intent.putExtra(INTENT_UID, uid) + intent.putExtra(INTENT_ACTIVE_CONNS, activeConns) startActivity(intent) } - private fun openAppWiseIpLogsActivity() { + private fun openAppWiseIpLogsActivity(asn: Boolean = false) { val intent = Intent(this, AppWiseIpLogsActivity::class.java) - intent.putExtra(UID_INTENT_NAME, uid) + intent.putExtra(INTENT_UID, uid) + intent.putExtra(INTENT_ASN, asn) startActivity(intent) } + private fun setActiveConnsAdapter() { + val layoutManager = LinearLayoutManager(this) + b.aadActiveConnsRv.layoutManager = layoutManager + val adapter = AppWiseDomainsAdapter(this, this, uid, isRethinkApp, isActiveConn = true) + val uptime = VpnController.uptimeMs() + networkLogsViewModel.fetchTopActiveConnections(uid, uptime).observe(this) { + adapter.submitData(this.lifecycle, it) + } + b.aadActiveConnsRv.adapter = adapter + + adapter.addLoadStateListener { + if (it.append.endOfPaginationReached) { + if (adapter.itemCount >= 1) { + b.aadActiveConnsRl.visibility = View.VISIBLE + } else { + b.aadActiveConnsRl.visibility = View.GONE + } + } + } + } + + private fun setASNAdapter() { + val layoutManager = LinearLayoutManager(this) + b.aadAsnRv.layoutManager = layoutManager + val adapter = AppWiseIpsAdapter(this, this, uid, isRethinkApp, isAsn = true) + networkLogsViewModel.getAsnLogsLimited(uid).observe(this) { + adapter.submitData(this.lifecycle, it) + } + b.aadAsnRv.adapter = adapter + + adapter.addLoadStateListener { + if (it.append.endOfPaginationReached) { + if (adapter.itemCount >= 1) { + b.aadAsnRl.visibility = View.VISIBLE + } else { + b.aadAsnRl.visibility = View.GONE + } + } + } + } + private fun setDomainsAdapter() { val layoutManager = LinearLayoutManager(this) b.aadMostContactedDomainRv.layoutManager = layoutManager - val adapter = AppWiseDomainsAdapter(this, this, uid) + val adapter = AppWiseDomainsAdapter(this, this, uid, isRethinkApp) networkLogsViewModel.getDomainLogsLimited(uid).observe(this) { adapter.submitData(this.lifecycle, it) } @@ -380,11 +492,50 @@ class AppInfoActivity : AppCompatActivity(R.layout.activity_app_details) { adapter.addLoadStateListener { if (it.append.endOfPaginationReached) { - if (adapter.itemCount < 1) { - b.aadMostContactedDomainRl.visibility = View.GONE + if (adapter.itemCount >= 1) { + b.aadMostContactedDomainRl.visibility = View.VISIBLE } else { + b.aadMostContactedDomainRl.visibility = View.GONE + } + } + } + } + + private fun setRethinkDomainLogsAdapter() { + val layoutManager = LinearLayoutManager(this) + b.aadMostContactedDomainRv.layoutManager = layoutManager + val adapter = AppWiseDomainsAdapter(this, this, uid, isRethinkApp) + networkLogsViewModel.getRethinkDomainLogsLimited().observe(this) { + adapter.submitData(this.lifecycle, it) + } + b.aadMostContactedDomainRv.adapter = adapter + + adapter.addLoadStateListener { + if (it.append.endOfPaginationReached) { + if (adapter.itemCount >= 1) { b.aadMostContactedDomainRl.visibility = View.VISIBLE + } else { + b.aadMostContactedDomainRl.visibility = View.GONE + } + } + } + } + + private fun setRethinkIpLogsAdapter() { + val layoutManager = LinearLayoutManager(this) + b.aadMostContactedIpsRv.layoutManager = layoutManager + val adapter = AppWiseIpsAdapter(this, this, uid, isRethinkApp) + networkLogsViewModel.getRethinkIpLogsLimited().observe(this) { + adapter.submitData(this.lifecycle, it) + } + b.aadMostContactedIpsRv.adapter = adapter + adapter.addLoadStateListener { + if (it.append.endOfPaginationReached) { + if (adapter.itemCount >= 1) { + b.aadMostContactedIpsRl.visibility = View.VISIBLE + } else { + b.aadMostContactedIpsRl.visibility = View.GONE } } } @@ -394,7 +545,7 @@ class AppInfoActivity : AppCompatActivity(R.layout.activity_app_details) { b.aadMostContactedIpsRv.setHasFixedSize(true) val layoutManager = LinearLayoutManager(this) b.aadMostContactedIpsRv.layoutManager = layoutManager - val adapter = AppWiseIpsAdapter(this, this, uid) + val adapter = AppWiseIpsAdapter(this, this, uid, isRethinkApp) networkLogsViewModel.getIpLogsLimited(uid).observe(this) { adapter.submitData(this.lifecycle, it) } @@ -402,25 +553,24 @@ class AppInfoActivity : AppCompatActivity(R.layout.activity_app_details) { adapter.addLoadStateListener { if (it.append.endOfPaginationReached) { - if (adapter.itemCount < 1) { - b.aadMostContactedIpsRl.visibility = View.GONE - } else { + if (adapter.itemCount >= 1) { b.aadMostContactedIpsRl.visibility = View.VISIBLE + } else { + b.aadMostContactedIpsRl.visibility = View.GONE } } } } - private fun showAppInfoDialog(packages: List) { + private fun showAppInfoDialog(appNames: List) { val builderSingle = MaterialAlertDialogBuilder(this) - builderSingle.setTitle(this.getString(R.string.about_settings_app_info)) val arrayAdapter = ArrayAdapter(this, android.R.layout.simple_list_item_activated_1) - arrayAdapter.addAll(packages) + arrayAdapter.addAll(appNames) builderSingle.setCancelable(false) - builderSingle.setItems(packages.toTypedArray(), null) + builderSingle.setItems(appNames.toTypedArray(), null) builderSingle.setPositiveButton(getString(R.string.ada_noapp_dialog_positive)) { dialog: DialogInterface, @@ -429,7 +579,16 @@ class AppInfoActivity : AppCompatActivity(R.layout.activity_app_details) { } val alertDialog = builderSingle.create() - alertDialog.listView.setOnItemClickListener { _, _, _, _ -> } + val ctx = this.applicationContext + alertDialog.listView.setOnItemClickListener { _, _, position, _ -> + io { + val pkg = FirewallManager.getPackageNameByAppName(appNames[position]) + uiCtx { + Logger.i(Logger.LOG_TAG_UI, "AppInfoActivity, package name: $pkg") + openAndroidAppInfo(ctx, pkg) + } + } + } alertDialog.show() } @@ -458,7 +617,7 @@ class AppInfoActivity : AppCompatActivity(R.layout.activity_app_details) { FirewallManager.ConnectionStatus.METERED } } - updateFirewallStatus(FirewallManager.FirewallStatus.NONE, cStat) + updateFirewallStatus(FirewallManager.FirewallStatus.NONE, cStat, connStatus) } } } @@ -489,20 +648,21 @@ class AppInfoActivity : AppCompatActivity(R.layout.activity_app_details) { } } - updateFirewallStatus(FirewallManager.FirewallStatus.NONE, cStat) + updateFirewallStatus(FirewallManager.FirewallStatus.NONE, cStat, connStatus) } } } private fun updateFirewallStatus( aStat: FirewallManager.FirewallStatus, - cStat: FirewallManager.ConnectionStatus + cStat: FirewallManager.ConnectionStatus, + prevConnStat: FirewallManager.ConnectionStatus = FirewallManager.ConnectionStatus.ALLOW ) { io { val appNames = FirewallManager.getAppNamesByUid(appInfo.uid) uiCtx { if (appNames.count() > 1) { - showDialog(appNames, appInfo, aStat, cStat) + showDialog(appNames, appInfo, aStat, cStat, prevConnStat) return@uiCtx } @@ -555,14 +715,6 @@ class AppInfoActivity : AppCompatActivity(R.layout.activity_app_details) { setDrawable(R.drawable.ic_bypass_dns_firewall_off, b.aadAppSettingsBypassDnsFirewall) } - private fun disableFirewallStatusUi() { - setDrawable(R.drawable.ic_firewall_bypass_off, b.aadAppSettingsBypassUniv) - setDrawable(R.drawable.ic_firewall_exclude_off, b.aadAppSettingsExclude) - setDrawable(R.drawable.ic_firewall_lockdown_off, b.aadAppSettingsIsolate) - setDrawable(R.drawable.ic_firewall_wifi_on_grey, b.aadAppSettingsBlockWifi) - setDrawable(R.drawable.ic_firewall_data_on_grey, b.aadAppSettingsBlockMd) - } - private fun enableIsolateUi() { setDrawable(R.drawable.ic_bypass_dns_firewall_off, b.aadAppSettingsBypassDnsFirewall) setDrawable(R.drawable.ic_firewall_wifi_on_grey, b.aadAppSettingsBlockWifi) @@ -666,7 +818,8 @@ class AppInfoActivity : AppCompatActivity(R.layout.activity_app_details) { packageList: List, appInfo: AppInfo, aStat: FirewallManager.FirewallStatus, - cStat: FirewallManager.ConnectionStatus + cStat: FirewallManager.ConnectionStatus, + prevConnStat: FirewallManager.ConnectionStatus ) { val builderSingle = MaterialAlertDialogBuilder(this) @@ -684,7 +837,7 @@ class AppInfoActivity : AppCompatActivity(R.layout.activity_app_details) { builderSingle.setItems(packageList.toTypedArray(), null) builderSingle - .setPositiveButton(getString(FirewallManager.getLabelForStatus(aStat, cStat))) { + .setPositiveButton(getString(FirewallManager.getLabelForStatus(aStat, cStat, prevConnStat))) { di: DialogInterface, _: Int -> di.dismiss() @@ -700,6 +853,28 @@ class AppInfoActivity : AppCompatActivity(R.layout.activity_app_details) { alertDialog.show() } + private fun showCloseConnectionDialog(uid: Int, appName: String) { + if (isRethinkApp) { + Logger.i(LOG_TAG_UI, "$TAG rethink connection - no close connection dialog") + return + } + Logger.v(LOG_TAG_UI, "$TAG show close connection dialog for uid: $uid") + val dialog = MaterialAlertDialogBuilder(this) + .setTitle(this.getString(R.string.close_conns_dialog_title)) + .setMessage(getString(R.string.close_conns_dialog_desc, appName)) + .setPositiveButton(R.string.lbl_proceed) { _, _ -> + // close the connection + VpnController.closeConnectionsIfNeeded(uid) + Logger.i(LOG_TAG_UI, "$TAG closed connection for uid: $uid") + showToastUiCentered(this, getString(R.string.config_add_success_toast), Toast.LENGTH_LONG) + } + .setNegativeButton(R.string.lbl_cancel, null) + .create() + dialog.setCancelable(true) + dialog.setCanceledOnTouchOutside(true) + dialog.show() + } + private fun displayIcon(drawable: Drawable?, mIconImageView: ImageView) { Glide.with(this).load(drawable).error(Utilities.getDefaultIcon(this)).into(mIconImageView) } diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/AppListActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/AppListActivity.kt index 37414bcdb..283bf9ecb 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/activity/AppListActivity.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/activity/AppListActivity.kt @@ -19,11 +19,15 @@ import android.content.Context import android.content.res.Configuration import android.graphics.PorterDuff import android.graphics.PorterDuffColorFilter +import android.graphics.Rect import android.os.Bundle +import android.util.TypedValue import android.view.LayoutInflater import android.view.View +import android.view.ViewTreeObserver import android.view.animation.Animation import android.view.animation.RotateAnimation +import android.view.inputmethod.InputMethodManager import android.widget.CompoundButton import android.widget.Toast import androidx.appcompat.app.AlertDialog @@ -31,8 +35,10 @@ import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.TooltipCompat import androidx.core.content.ContextCompat +import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.MutableLiveData import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import by.kirich1409.viewbindingdelegate.viewBinding import com.celzero.bravedns.R @@ -41,10 +47,10 @@ import com.celzero.bravedns.database.RefreshDatabase import com.celzero.bravedns.databinding.ActivityAppListBinding import com.celzero.bravedns.service.PersistentState import com.celzero.bravedns.ui.bottomsheet.FirewallAppFilterBottomSheet -import com.celzero.bravedns.util.CustomLinearLayoutManager import com.celzero.bravedns.util.Themes import com.celzero.bravedns.util.UIUtils import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.isAtleastQ import com.celzero.bravedns.viewmodel.AppInfoViewModel import com.google.android.material.chip.Chip import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -77,7 +83,7 @@ class AppListActivity : private const val ANIMATION_END_DEGREE = 360.0f private const val REFRESH_TIMEOUT: Long = 4000 - private const val QUERY_TEXT_TIMEOUT: Long = 600 + private const val QUERY_TEXT_TIMEOUT: Long = 1000 } // enum class for bulk ui update @@ -116,14 +122,18 @@ class AppListActivity : ALL(0), ALLOWED(1), BLOCKED(2), - BYPASS(3), - EXCLUDED(4), - LOCKDOWN(5); + BLOCKED_WIFI(3), + BLOCKED_MOBILE_DATA(4), + BYPASS(5), + EXCLUDED(6), + LOCKDOWN(7); fun getFilter(): Set { return when (this) { ALL -> setOf(0, 1, 2, 3, 4, 5, 7) ALLOWED -> setOf(5) + BLOCKED_WIFI -> setOf(5) + BLOCKED_MOBILE_DATA -> setOf(5) BLOCKED -> setOf(5) BYPASS -> setOf(2, 7) EXCLUDED -> setOf(3) @@ -135,7 +145,9 @@ class AppListActivity : return when (this) { ALL -> setOf(0, 1, 2, 3) ALLOWED -> setOf(3) - BLOCKED -> setOf(0, 1, 2) + BLOCKED_WIFI -> setOf(1) + BLOCKED_MOBILE_DATA -> setOf(2) + BLOCKED -> setOf(0) BYPASS -> setOf(0, 1, 2, 3) EXCLUDED -> setOf(0, 1, 2, 3) LOCKDOWN -> setOf(0, 1, 2, 3) @@ -146,6 +158,8 @@ class AppListActivity : return when (this) { ALL -> context.getString(R.string.lbl_all) ALLOWED -> context.getString(R.string.lbl_allowed) + BLOCKED_WIFI -> context.getString(R.string.two_argument_colon, context.getString(R.string.lbl_blocked), context.getString(R.string.firewall_rule_block_unmetered)) + BLOCKED_MOBILE_DATA -> context.getString(R.string.two_argument_colon, context.getString(R.string.lbl_blocked), context.getString(R.string.firewall_rule_block_metered)) BLOCKED -> context.getString(R.string.lbl_blocked) BYPASS -> context.getString(R.string.fapps_firewall_filter_bypass_universal) EXCLUDED -> context.getString(R.string.fapps_firewall_filter_excluded) @@ -158,6 +172,8 @@ class AppListActivity : return when (id) { ALL.id -> ALL ALLOWED.id -> ALLOWED + BLOCKED_WIFI.id -> BLOCKED_WIFI + BLOCKED_MOBILE_DATA.id -> BLOCKED_MOBILE_DATA BLOCKED.id -> BLOCKED BYPASS.id -> BYPASS EXCLUDED.id -> EXCLUDED @@ -183,6 +199,13 @@ class AppListActivity : override fun onCreate(savedInstanceState: Bundle?) { setTheme(Themes.getCurrentTheme(isDarkThemeOn(), persistentState.theme)) super.onCreate(savedInstanceState) + + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + initView() initObserver() setupClickListener() @@ -191,20 +214,17 @@ class AppListActivity : override fun onResume() { super.onResume() setFirewallFilter(filters.value?.firewallFilter) + b.ffaAppList.requestFocus() } private fun initObserver() { filters.observe(this) { // update the ui based on the filter resetFirewallIcons(BlockType.UNMETER) - if (it == null) return@observe - ui { - appInfoViewModel.setFilter(it) - b.ffaAppList.smoothScrollToPosition(0) - updateFilterText(it) - } + appInfoViewModel.setFilter(it) + updateFilterText(it) } } @@ -217,9 +237,7 @@ class AppListActivity : getString( R.string.fapps_firewall_filter_desc, firewallLabel.lowercase(), - filterLabel - ) - ) + filterLabel)) } else { b.firewallAppLabelTv.text = UIUtils.updateHtmlEncodedText( @@ -227,20 +245,26 @@ class AppListActivity : R.string.fapps_firewall_filter_desc_category, firewallLabel.lowercase(), filterLabel, - filter.categoryFilters - ) - ) + filter.categoryFilters)) } b.firewallAppLabelTv.isSelected = true } override fun onPause() { - filters.postValue(Filters()) + b.ffaSearch.clearFocus() + b.ffaAppList.requestFocus() super.onPause() } + override fun onStop() { + super.onStop() + // clear the filters when the activity is stopped + filters.value = Filters() + } + override fun onQueryTextSubmit(query: String): Boolean { addQueryToFilters(query) + b.ffaSearch.clearFocus() return true } @@ -278,10 +302,7 @@ class AppListActivity : b.ffaRefreshList.isEnabled = true b.ffaRefreshList.clearAnimation() Utilities.showToastUiCentered( - this, - getString(R.string.refresh_complete), - Toast.LENGTH_SHORT - ) + this, getString(R.string.refresh_complete), Toast.LENGTH_SHORT) } } } @@ -290,30 +311,27 @@ class AppListActivity : showBulkRulesUpdateDialog( getBulkActionDialogTitle(BlockType.UNMETER), getBulkActionDialogMessage(BlockType.UNMETER), - BlockType.UNMETER - ) + BlockType.UNMETER) } b.ffaToggleAllMobileData.setOnClickListener { showBulkRulesUpdateDialog( getBulkActionDialogTitle(BlockType.METER), getBulkActionDialogMessage(BlockType.METER), - BlockType.METER - ) + BlockType.METER) } b.ffaToggleAllLockdown.setOnClickListener { showBulkRulesUpdateDialog( getBulkActionDialogTitle(BlockType.LOCKDOWN), getBulkActionDialogMessage(BlockType.LOCKDOWN), - BlockType.LOCKDOWN - ) + BlockType.LOCKDOWN) } TooltipCompat.setTooltipText( b.ffaToggleAllBypassDnsFirewall, - getString(R.string.bypass_dns_firewall_tooltip, getString(R.string.bypass_dns_firewall)) - ) + getString( + R.string.bypass_dns_firewall_tooltip, getString(R.string.bypass_dns_firewall))) b.ffaToggleAllBypassDnsFirewall.setOnClickListener { // show tooltip once the user clicks on the button @@ -326,24 +344,21 @@ class AppListActivity : showBulkRulesUpdateDialog( getBulkActionDialogTitle(BlockType.BYPASS_DNS_FIREWALL), getBulkActionDialogMessage(BlockType.BYPASS_DNS_FIREWALL), - BlockType.BYPASS_DNS_FIREWALL - ) + BlockType.BYPASS_DNS_FIREWALL) } b.ffaToggleAllBypass.setOnClickListener { showBulkRulesUpdateDialog( getBulkActionDialogTitle(BlockType.BYPASS), getBulkActionDialogMessage(BlockType.BYPASS), - BlockType.BYPASS - ) + BlockType.BYPASS) } b.ffaToggleAllExclude.setOnClickListener { showBulkRulesUpdateDialog( getBulkActionDialogTitle(BlockType.EXCLUDE), getBulkActionDialogMessage(BlockType.EXCLUDE), - BlockType.EXCLUDE - ) + BlockType.EXCLUDE) } b.ffaAppInfoIcon.setOnClickListener { showInfoDialog() } @@ -479,7 +494,7 @@ class AppListActivity : } private fun showInfoDialog() { - val li = getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater + val li = getSystemService(LAYOUT_INFLATER_SERVICE) as LayoutInflater val view: View = li.inflate(R.layout.dialog_info_firewall_rules, null) val builder = MaterialAlertDialogBuilder(this).setView(view) builder.setPositiveButton(getString(R.string.fapps_info_dialog_positive_btn)) { dialog, _ -> @@ -505,28 +520,42 @@ class AppListActivity : makeFirewallChip(FirewallFilter.ALLOWED.id, getString(R.string.lbl_allowed), false) val blocked = makeFirewallChip(FirewallFilter.BLOCKED.id, getString(R.string.lbl_blocked), false) + val blockedWifiTxt = getString( + R.string.two_argument_colon, + getString(R.string.lbl_blocked), + getString(R.string.firewall_rule_block_unmetered) + ) + val blockedWifi = + makeFirewallChip(FirewallFilter.BLOCKED_WIFI.id, blockedWifiTxt, false) + val blockedMobileDataTxt = getString( + R.string.two_argument_colon, + getString(R.string.lbl_blocked), + getString(R.string.firewall_rule_block_metered) + ) + val blockedMobileData = + makeFirewallChip(FirewallFilter.BLOCKED_MOBILE_DATA.id, blockedMobileDataTxt, false) + val bypassUniversal = makeFirewallChip( FirewallFilter.BYPASS.id, getString(R.string.fapps_firewall_filter_bypass_universal), - false - ) + false) val excluded = makeFirewallChip( FirewallFilter.EXCLUDED.id, getString(R.string.fapps_firewall_filter_excluded), - false - ) + false) val lockdown = makeFirewallChip( FirewallFilter.LOCKDOWN.id, getString(R.string.fapps_firewall_filter_isolate), - false - ) + false) b.ffaFirewallChipGroup.addView(none) b.ffaFirewallChipGroup.addView(allowed) b.ffaFirewallChipGroup.addView(blocked) + b.ffaFirewallChipGroup.addView(blockedWifi) + b.ffaFirewallChipGroup.addView(blockedMobileData) b.ffaFirewallChipGroup.addView(bypassUniversal) b.ffaFirewallChipGroup.addView(excluded) b.ffaFirewallChipGroup.addView(lockdown) @@ -567,9 +596,7 @@ class AppListActivity : private fun colorUpChipIcon(chip: Chip) { val colorFilter = PorterDuffColorFilter( - ContextCompat.getColor(this, R.color.primaryText), - PorterDuff.Mode.SRC_IN - ) + ContextCompat.getColor(this, R.color.primaryText), PorterDuff.Mode.SRC_IN) chip.checkedIcon?.colorFilter = colorFilter chip.chipIcon?.colorFilter = colorFilter } @@ -583,8 +610,7 @@ class AppListActivity : b.ffaToggleAllBypass.setImageResource(R.drawable.ic_firewall_bypass_off) b.ffaToggleAllLockdown.setImageResource(R.drawable.ic_firewall_lockdown_off) b.ffaToggleAllBypassDnsFirewall.setImageResource( - R.drawable.ic_bypass_dns_firewall_off - ) + R.drawable.ic_bypass_dns_firewall_off) } BlockType.METER -> { b.ffaToggleAllWifi.setImageResource(R.drawable.ic_firewall_wifi_on_grey) @@ -592,8 +618,7 @@ class AppListActivity : b.ffaToggleAllBypass.setImageResource(R.drawable.ic_firewall_bypass_off) b.ffaToggleAllLockdown.setImageResource(R.drawable.ic_firewall_lockdown_off) b.ffaToggleAllBypassDnsFirewall.setImageResource( - R.drawable.ic_bypass_dns_firewall_off - ) + R.drawable.ic_bypass_dns_firewall_off) } BlockType.LOCKDOWN -> { b.ffaToggleAllMobileData.setImageResource(R.drawable.ic_firewall_data_on_grey) @@ -601,8 +626,7 @@ class AppListActivity : b.ffaToggleAllExclude.setImageResource(R.drawable.ic_firewall_exclude_off) b.ffaToggleAllBypass.setImageResource(R.drawable.ic_firewall_bypass_off) b.ffaToggleAllBypassDnsFirewall.setImageResource( - R.drawable.ic_bypass_dns_firewall_off - ) + R.drawable.ic_bypass_dns_firewall_off) } BlockType.BYPASS -> { b.ffaToggleAllMobileData.setImageResource(R.drawable.ic_firewall_data_on_grey) @@ -610,8 +634,7 @@ class AppListActivity : b.ffaToggleAllExclude.setImageResource(R.drawable.ic_firewall_exclude_off) b.ffaToggleAllLockdown.setImageResource(R.drawable.ic_firewall_lockdown_off) b.ffaToggleAllBypassDnsFirewall.setImageResource( - R.drawable.ic_bypass_dns_firewall_off - ) + R.drawable.ic_bypass_dns_firewall_off) } BlockType.BYPASS_DNS_FIREWALL -> { b.ffaToggleAllMobileData.setImageResource(R.drawable.ic_firewall_data_on_grey) @@ -626,8 +649,7 @@ class AppListActivity : b.ffaToggleAllBypass.setImageResource(R.drawable.ic_firewall_bypass_off) b.ffaToggleAllLockdown.setImageResource(R.drawable.ic_firewall_lockdown_off) b.ffaToggleAllBypassDnsFirewall.setImageResource( - R.drawable.ic_bypass_dns_firewall_off - ) + R.drawable.ic_bypass_dns_firewall_off) } } } @@ -719,14 +741,58 @@ class AppListActivity : b.ffaSearch.setOnQueryTextListener(this) addAnimation() remakeFirewallChipsUi() + handleKeyboardEvent() + } + + private fun handleKeyboardEvent() { + // ref: stackoverflow.com/a/36259261 + val rootView = findViewById(android.R.id.content) + + rootView.viewTreeObserver.addOnGlobalLayoutListener(object : + ViewTreeObserver.OnGlobalLayoutListener { + private var alreadyOpen = false + private val defaultKeyboardHeightDP = 100 + private val EstimatedKeyboardDP = defaultKeyboardHeightDP + 48 + private val rect = Rect() + + override fun onGlobalLayout() { + val estimatedKeyboardHeight = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + EstimatedKeyboardDP.toFloat(), + rootView.resources.displayMetrics + ).toInt() + rootView.getWindowVisibleDisplayFrame(rect) + val heightDiff = rootView.rootView.height - (rect.bottom - rect.top) + val isShown = heightDiff >= estimatedKeyboardHeight + + if (isShown == alreadyOpen) { + return // nothing to do + } + + alreadyOpen = isShown + + if (!isShown) { + if (b.ffaSearch.hasFocus()) { + // clear focus from search view when keyboard is closed + b.ffaSearch.clearFocus() + } + } + } + }) } private fun initListAdapter() { + val recyclerAdapter = FirewallAppListAdapter(this, this) b.ffaAppList.setHasFixedSize(true) - layoutManager = CustomLinearLayoutManager(this) + layoutManager = LinearLayoutManager(this) b.ffaAppList.layoutManager = layoutManager - val recyclerAdapter = FirewallAppListAdapter(this, this) - appInfoViewModel.appInfo.observe(this) { recyclerAdapter.submitData(this.lifecycle, it) } + recyclerAdapter.stateRestorationPolicy = + RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY + + appInfoViewModel.appInfo.observe(this) { + b.ffaAppList.post { recyclerAdapter.submitData(lifecycle, it) } + } + b.ffaAppList.adapter = recyclerAdapter } @@ -743,8 +809,7 @@ class AppListActivity : Animation.RELATIVE_TO_SELF, ANIMATION_PIVOT_VALUE, Animation.RELATIVE_TO_SELF, - ANIMATION_PIVOT_VALUE - ) + ANIMATION_PIVOT_VALUE) animation.repeatCount = ANIMATION_REPEAT_COUNT animation.duration = ANIMATION_DURATION } @@ -756,8 +821,4 @@ class AppListActivity : private fun io(f: suspend () -> Unit) { lifecycleScope.launch(Dispatchers.IO) { f() } } - - private fun ui(f: () -> Unit) { - lifecycleScope.launch(Dispatchers.Main) { f() } - } } diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/AppLockActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/AppLockActivity.kt new file mode 100644 index 000000000..7dd77933d --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/ui/activity/AppLockActivity.kt @@ -0,0 +1,183 @@ +/* + * Copyright 2025 RethinkDNS and its authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.celzero.bravedns.ui.activity + +import Logger +import Logger.LOG_TAG_UI +import android.app.ComponentCaller +import android.app.UiModeManager +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import android.os.Bundle +import android.os.SystemClock +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.core.view.WindowInsetsControllerCompat +import com.celzero.bravedns.R +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.ui.HomeScreenActivity +import com.celzero.bravedns.ui.LauncherSwitcher +import com.celzero.bravedns.util.Themes.Companion.getCurrentTheme +import com.celzero.bravedns.util.Utilities.isAtleastQ +import com.celzero.bravedns.util.Utilities.showToastUiCentered +import org.koin.android.ext.android.inject +import java.util.concurrent.Executor +import java.util.concurrent.TimeUnit +import kotlin.math.abs + +class AppLockActivity : AppCompatActivity(R.layout.activity_app_lock) { + private val persistentState by inject() + + private lateinit var executor: Executor + private lateinit var biometricPrompt: BiometricPrompt + + companion object { + private const val TAG = "AppLockUi" + const val APP_LOCK_ALIAS = ".ui.activity.LauncherAliasAppLock" + const val HOME_ALIAS = ".ui.LauncherAliasHome" + } + + // TODO - #324 - Usage of isDarkTheme() in all activities. + private fun Context.isDarkThemeOn(): Boolean { + return resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == + UI_MODE_NIGHT_YES + } + + override fun onCreate(savedInstanceState: Bundle?) { + setTheme(getCurrentTheme(isDarkThemeOn(), persistentState.theme)) + super.onCreate(savedInstanceState) + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + + if (!isBiometricEnabled() || isAppRunningOnTv()) { + Logger.v(LOG_TAG_UI, "$TAG biometric authentication disabled or running on TV") + + // if the app lock alias is enabled, switch to home alias + if (!LauncherSwitcher.isAliasEnabled(applicationContext, APP_LOCK_ALIAS)) { + Logger.v(LOG_TAG_UI, "$TAG switching launcher alias to home") + startHomeActivity() + return + } + + // if the app lock alias is not enabled, switch to home alias + LauncherSwitcher.switchLauncherAlias(applicationContext, HOME_ALIAS, APP_LOCK_ALIAS) + + startHomeActivity() + return + } + + val lastAuthTime = persistentState.biometricAuthTime + + // if the biometric authentication is already done in the last configured mins, then skip + var delay = MiscSettingsActivity.BioMetricType.fromValue(persistentState.biometricAuthType).mins + + // this is for backward compatibility with older versions + // if enabled and lastUnlockTime is -1, then set it to 15 mins(maximum value) + delay = if (delay == -1L) { + MiscSettingsActivity.BioMetricType.FIFTEEN_MIN.mins + } else { + delay + } + + Logger.d(LOG_TAG_UI, "$TAG timeout: $delay, last auth: $lastAuthTime") + val timeSinceLastAuth = abs(SystemClock.elapsedRealtime() - lastAuthTime) + if (timeSinceLastAuth < TimeUnit.MINUTES.toMillis(delay)) { + Logger.i(LOG_TAG_UI, "$TAG biometric auth skipped, time since last auth: $timeSinceLastAuth") + startHomeActivity() + return + } + + showBiometricPrompt() + } + + override fun onNewIntent(intent: Intent, caller: ComponentCaller) { + super.onNewIntent(intent, caller) + setIntent(intent) + } + + private fun showBiometricPrompt() { + Logger.v(LOG_TAG_UI, "$TAG showing biometric prompt") + executor = ContextCompat.getMainExecutor(this) + biometricPrompt = BiometricPrompt(this, executor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + Logger.i(LOG_TAG_UI, "$TAG auth error(code: $errorCode): $errString") + Logger.v(LOG_TAG_UI, "$TAG biometric auth err, finishing activity") + showToastUiCentered(this@AppLockActivity, errString.toString(), Toast.LENGTH_SHORT) + finishAffinity() + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + persistentState.biometricAuthTime = SystemClock.elapsedRealtime() + startHomeActivity() + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + Logger.i(LOG_TAG_UI, "$TAG biometric authentication failed") + } + }) + + val promptInfo = + BiometricPrompt.PromptInfo.Builder() + .setTitle(getString(R.string.hs_biometeric_title)) + .setSubtitle(getString(R.string.hs_biometeric_desc)) + .setAllowedAuthenticators( + BiometricManager.Authenticators.BIOMETRIC_WEAK or + BiometricManager.Authenticators.DEVICE_CREDENTIAL + ) + .setConfirmationRequired(false) + .build() + + biometricPrompt.authenticate(promptInfo) + } + + private fun startHomeActivity() { + Logger.v(LOG_TAG_UI, "$TAG starting home activity") + val intent = Intent(this, HomeScreenActivity::class.java) + // Use a specific combination of flags that will maintain the proper back stack + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + intent.putExtras(this.intent) + startActivity(intent) + finish() + } + + private fun isBiometricEnabled(): Boolean { + val type = MiscSettingsActivity.BioMetricType.fromValue(persistentState.biometricAuthType) + // use the biometricAuth flag for backward compatibility with older version + return type.enabled() + } + + // check if app running on TV + private fun isAppRunningOnTv(): Boolean { + return try { + val uiModeManager: UiModeManager = getSystemService(UI_MODE_SERVICE) as UiModeManager + uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION + } catch (ignored: Exception) { + false + } + } +} diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/AppWiseDomainLogsActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/AppWiseDomainLogsActivity.kt index c63382b97..8f3972c5b 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/activity/AppWiseDomainLogsActivity.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/activity/AppWiseDomainLogsActivity.kt @@ -15,14 +15,19 @@ */ package com.celzero.bravedns.ui.activity +import Logger +import Logger.LOG_TAG_UI import android.content.Context import android.content.res.ColorStateList import android.content.res.Configuration import android.graphics.drawable.Drawable import android.os.Bundle +import android.view.View +import android.view.inputmethod.InputMethodManager import android.widget.ImageView import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.SearchView +import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -31,7 +36,6 @@ import com.bumptech.glide.Glide import com.celzero.bravedns.R import com.celzero.bravedns.adapter.AppWiseDomainsAdapter import com.celzero.bravedns.database.AppInfo -import com.celzero.bravedns.database.ConnectionTrackerRepository import com.celzero.bravedns.databinding.ActivityAppWiseDomainLogsBinding import com.celzero.bravedns.service.FirewallManager import com.celzero.bravedns.service.PersistentState @@ -39,6 +43,7 @@ import com.celzero.bravedns.util.Constants.Companion.INVALID_UID import com.celzero.bravedns.util.Themes import com.celzero.bravedns.util.UIUtils import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.isAtleastQ import com.celzero.bravedns.viewmodel.AppConnectionsViewModel import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButtonToggleGroup @@ -50,17 +55,21 @@ import kotlinx.coroutines.withContext import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel - class AppWiseDomainLogsActivity : AppCompatActivity(R.layout.activity_app_wise_domain_logs), SearchView.OnQueryTextListener { private val b by viewBinding(ActivityAppWiseDomainLogsBinding::bind) private val persistentState by inject() private val networkLogsViewModel: AppConnectionsViewModel by viewModel() - private val connectionTrackerRepository by inject() private var uid: Int = INVALID_UID private var layoutManager: RecyclerView.LayoutManager? = null private lateinit var appInfo: AppInfo + private var isRethink = false + private var isActiveConns = false + + companion object { + private const val QUERY_TEXT_DELAY: Long = 1000 + } private fun Context.isDarkThemeOn(): Boolean { return resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == @@ -70,14 +79,46 @@ class AppWiseDomainLogsActivity : override fun onCreate(savedInstanceState: Bundle?) { setTheme(Themes.getCurrentTheme(isDarkThemeOn(), persistentState.theme)) super.onCreate(savedInstanceState) - uid = intent.getIntExtra(AppInfoActivity.UID_INTENT_NAME, INVALID_UID) + + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + uid = intent.getIntExtra(AppInfoActivity.INTENT_UID, INVALID_UID) + isActiveConns = intent.getBooleanExtra(AppInfoActivity.INTENT_ACTIVE_CONNS, false) + if (uid == INVALID_UID) { finish() } - init() - setAdapter() - observeNetworkLogSize() - setClickListener() + if (Utilities.getApplicationInfo(this, this.packageName)?.uid == uid) { + isRethink = true + init() + setRethinkAdapter() + b.toggleGroup.addOnButtonCheckedListener(listViewToggleListener) + } else { + init() + if (isActiveConns) { + setActiveConnsAdapter() + } else { + setAdapter() + } + setClickListener() + } + } + + override fun onResume() { + super.onResume() + // fix for #1939, OEM-specific bug, especially on heavily customized Android + // some ROMs kill or freeze the keyboard/IME process to save memory or battery, + // causing SearchView to stop receiving input events + // this is a workaround to restart the IME process + b.awlSearch.setQuery("", false) + b.awlSearch.clearFocus() + + val imm = this.getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager + imm.restartInput(b.awlSearch) + b.awlRecyclerConnection.requestFocus() } private fun setTabbedViewTxt() { @@ -91,10 +132,10 @@ class AppWiseDomainLogsActivity : val mb: MaterialButton = b.toggleGroup.findViewById(checkedId) if (isChecked) { selectToggleBtnUi(mb) - val tcValue = (mb.tag as String).toIntOrNull() ?: 0 + val tcValue = (mb.tag as String).toIntOrNull() ?: 2 // "2" tag is for 7 days val timeCategory = AppConnectionsViewModel.TimeCategory.fromValue(tcValue) - ?: AppConnectionsViewModel.TimeCategory.ONE_HOUR + ?: AppConnectionsViewModel.TimeCategory.SEVEN_DAYS networkLogsViewModel.timeCategoryChanged(timeCategory, true) return@OnButtonCheckedListener } @@ -119,8 +160,15 @@ class AppWiseDomainLogsActivity : } private fun init() { - setTabbedViewTxt() - highlightToggleBtn() + if (!isActiveConns) { + setTabbedViewTxt() + highlightToggleBtn() + } else { + // no need to show toggle button and delete button for active connections + b.toggleGroup.visibility = View.GONE + b.awlDelete.visibility = View.GONE + } + io { val appInfo = FirewallManager.getAppInfoByUid(uid) // case: app is uninstalled but still available in RethinkDNS database @@ -145,14 +193,27 @@ class AppWiseDomainLogsActivity : private fun updateAppNameInSearchHint(appName: String) { val appNameTruncated = appName.substring(0, appName.length.coerceAtMost(10)) - val hint = getString(R.string.two_argument_colon, appNameTruncated, getString(R.string.search_custom_domains)) + val hint = if (isActiveConns) { + getString( + R.string.two_argument_colon, + appNameTruncated, + getString(R.string.search_universal_ips) + ) + } else { + getString( + R.string.two_argument_colon, + appNameTruncated, + getString(R.string.search_custom_domains) + ) + } b.awlSearch.queryHint = hint + b.awlSearch.findViewById(androidx.appcompat.R.id.search_src_text).textSize = 14f return } private fun highlightToggleBtn() { - val timeCategory = "0" // default is 1 hours, "0" tag is 1 hours + val timeCategory = "2" // default is 7 days, "2" tag is for 7 days val btn = b.toggleGroup.findViewWithTag(timeCategory) btn.isChecked = true selectToggleBtnUi(btn) @@ -180,60 +241,140 @@ class AppWiseDomainLogsActivity : Glide.with(this).load(drawable).error(Utilities.getDefaultIcon(this)).into(mIconImageView) } - private fun setAdapter() { + private fun setActiveConnsAdapter() { + Logger.v(LOG_TAG_UI, "setActiveConnsAdapter: uid: $uid, isRethink: $isRethink") networkLogsViewModel.setUid(uid) b.awlRecyclerConnection.setHasFixedSize(true) layoutManager = LinearLayoutManager(this) b.awlRecyclerConnection.layoutManager = layoutManager - val recyclerAdapter = AppWiseDomainsAdapter(this, this, uid) - networkLogsViewModel.appDomainLogs.observe(this) { + val recyclerAdapter = AppWiseDomainsAdapter(this, this, uid, isRethink, true) + networkLogsViewModel.activeConnections.observe(this) { recyclerAdapter.submitData(this.lifecycle, it) } b.awlRecyclerConnection.adapter = recyclerAdapter + + /*recyclerAdapter.addLoadStateListener { + if (it.append.endOfPaginationReached) { + if (networkLogsViewModel.filterQuery.isNotEmpty()) { + return@addLoadStateListener + } + if (recyclerAdapter.itemCount < 1) { + showNoRulesUi() + hideRulesUi() + } else { + hideNoRulesUi() + showRulesUi() + } + } else { + hideNoRulesUi() + showRulesUi() + } + }*/ } - private fun observeNetworkLogSize() { - networkLogsViewModel.getConnectionsCount(uid).observe(this) { - if (it == null) return@observe + private fun setAdapter() { + networkLogsViewModel.setUid(uid) + b.awlRecyclerConnection.setHasFixedSize(true) + layoutManager = LinearLayoutManager(this) + b.awlRecyclerConnection.layoutManager = layoutManager + val recyclerAdapter = AppWiseDomainsAdapter(this, this, uid, isRethink) + networkLogsViewModel.appDomainLogs.observe(this) { + recyclerAdapter.submitData(this.lifecycle, it) + } + b.awlRecyclerConnection.adapter = recyclerAdapter - if (it <= 0) { - showNoRulesUi() - hideRulesUi() - return@observe + /*recyclerAdapter.addLoadStateListener { + if (it.append.endOfPaginationReached) { + if (networkLogsViewModel.filterQuery.isNotEmpty()) { + return@addLoadStateListener + } + if (recyclerAdapter.itemCount < 1) { + showNoRulesUi() + hideRulesUi() + } else { + hideNoRulesUi() + showRulesUi() + } + } else { + hideNoRulesUi() + showRulesUi() } + }*/ + } - hideNoRulesUi() - showRulesUi() + private fun setRethinkAdapter() { + networkLogsViewModel.setUid(uid) + b.awlRecyclerConnection.setHasFixedSize(true) + layoutManager = LinearLayoutManager(this) + b.awlRecyclerConnection.layoutManager = layoutManager + val recyclerAdapter = AppWiseDomainsAdapter(this, this, uid, isRethink) + networkLogsViewModel.rinrDomainLogs.observe(this) { + recyclerAdapter.submitData(this.lifecycle, it) } + b.awlRecyclerConnection.adapter = recyclerAdapter + + /*recyclerAdapter.addLoadStateListener { + if (it.append.endOfPaginationReached) { + if (networkLogsViewModel.filterQuery.isNotEmpty()) { + return@addLoadStateListener + } + if (recyclerAdapter.itemCount < 1) { + showNoRulesUi() + hideRulesUi() + } else { + hideNoRulesUi() + showRulesUi() + } + } else { + hideNoRulesUi() + showRulesUi() + } + }*/ } - private fun showNoRulesUi() { - b.awlNoRulesRl.visibility = android.view.View.VISIBLE + // commenting for now, see if we can remove this later + /*private fun showNoRulesUi() { + b.awlNoRulesRl.visibility = View.VISIBLE + networkLogsViewModel.rinrDomainLogs.removeObservers(this) } private fun hideRulesUi() { - b.awlCardViewTop.visibility = android.view.View.GONE - b.awlRecyclerConnection.visibility = android.view.View.GONE + b.awlCardViewTop.visibility = View.GONE + b.awlRecyclerConnection.visibility = View.GONE } private fun hideNoRulesUi() { - b.awlNoRulesRl.visibility = android.view.View.GONE + b.awlNoRulesRl.visibility = View.GONE } private fun showRulesUi() { - b.awlCardViewTop.visibility = android.view.View.VISIBLE - b.awlRecyclerConnection.visibility = android.view.View.VISIBLE - } + b.awlCardViewTop.visibility = View.VISIBLE + b.awlRecyclerConnection.visibility = View.VISIBLE + }*/ override fun onQueryTextSubmit(query: String): Boolean { - networkLogsViewModel.setFilter(query, AppConnectionsViewModel.FilterType.DOMAIN) + Utilities.delay(QUERY_TEXT_DELAY, lifecycleScope) { + if (!this.isFinishing) { + val type = if (isActiveConns) { + AppConnectionsViewModel.FilterType.ACTIVE_CONNECTIONS + } else { + AppConnectionsViewModel.FilterType.DOMAIN + } + networkLogsViewModel.setFilter(query, type) + } + } return true } override fun onQueryTextChange(query: String): Boolean { - Utilities.delay(500, lifecycleScope) { + Utilities.delay(QUERY_TEXT_DELAY, lifecycleScope) { if (!this.isFinishing) { - networkLogsViewModel.setFilter(query, AppConnectionsViewModel.FilterType.DOMAIN) + val type = if (isActiveConns) { + AppConnectionsViewModel.FilterType.ACTIVE_CONNECTIONS + } else { + AppConnectionsViewModel.FilterType.DOMAIN + } + networkLogsViewModel.setFilter(query, type) } } return true @@ -251,7 +392,7 @@ class AppWiseDomainLogsActivity : } private fun deleteAppLogs() { - io { connectionTrackerRepository.clearLogsByUid(uid) } + io { networkLogsViewModel.deleteLogs(uid) } } private fun io(f: suspend () -> Unit): Job { diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/AppWiseIpLogsActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/AppWiseIpLogsActivity.kt index 3d3e0c9a5..9fd065759 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/activity/AppWiseIpLogsActivity.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/activity/AppWiseIpLogsActivity.kt @@ -15,14 +15,18 @@ */ package com.celzero.bravedns.ui.activity +import Logger.LOG_TAG_UI import android.content.Context import android.content.res.ColorStateList import android.content.res.Configuration import android.graphics.drawable.Drawable import android.os.Bundle +import android.view.View +import android.view.inputmethod.InputMethodManager import android.widget.ImageView import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.SearchView +import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -31,7 +35,6 @@ import com.bumptech.glide.Glide import com.celzero.bravedns.R import com.celzero.bravedns.adapter.AppWiseIpsAdapter import com.celzero.bravedns.database.AppInfo -import com.celzero.bravedns.database.ConnectionTrackerRepository import com.celzero.bravedns.databinding.ActivityAppWiseIpLogsBinding import com.celzero.bravedns.service.FirewallManager import com.celzero.bravedns.service.PersistentState @@ -39,6 +42,7 @@ import com.celzero.bravedns.util.Constants.Companion.INVALID_UID import com.celzero.bravedns.util.Themes import com.celzero.bravedns.util.UIUtils import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.isAtleastQ import com.celzero.bravedns.viewmodel.AppConnectionsViewModel import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButtonToggleGroup @@ -56,10 +60,15 @@ class AppWiseIpLogsActivity : private val persistentState by inject() private val networkLogsViewModel: AppConnectionsViewModel by viewModel() - private val connectionTrackerRepository by inject() private var uid: Int = INVALID_UID private var layoutManager: RecyclerView.LayoutManager? = null private lateinit var appInfo: AppInfo + private var isRethink = false + private var isAsn = false + + companion object { + private const val QUERY_TEXT_DELAY: Long = 1000 + } private fun Context.isDarkThemeOn(): Boolean { return resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == @@ -69,14 +78,48 @@ class AppWiseIpLogsActivity : override fun onCreate(savedInstanceState: Bundle?) { setTheme(Themes.getCurrentTheme(isDarkThemeOn(), persistentState.theme)) super.onCreate(savedInstanceState) - uid = intent.getIntExtra(AppInfoActivity.UID_INTENT_NAME, INVALID_UID) + + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + uid = intent.getIntExtra(AppInfoActivity.INTENT_UID, INVALID_UID) + isAsn = intent.getBooleanExtra(AppInfoActivity.INTENT_ASN, false) if (uid == INVALID_UID) { finish() } - init() - setAdapter() - observeNetworkLogSize() - setClickListener() + if (Utilities.getApplicationInfo(this, this.packageName)?.uid == uid) { + isRethink = true + init() + setRethinkAdapter() + b.toggleGroup.addOnButtonCheckedListener(listViewToggleListener) + } else { + init() + if (isAsn) { + // ASN view + // disable search view for ASN view, visibility should be there as the icon is used + b.awlSearch.isEnabled = false + b.awlDelete.visibility = View.GONE + setAsnAdapter() + } else { + setAdapter() + } + setClickListener() + } + } + + override fun onResume() { + super.onResume() + // fix for #1939, OEM-specific bug, especially on heavily customized Android + // some ROMs kill or freeze the keyboard/IME process to save memory or battery, + // causing SearchView to stop receiving input events + // this is a workaround to restart the IME process + b.awlSearch.setQuery("", false) + b.awlSearch.clearFocus() + + val imm = this.getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager + imm.restartInput(b.awlSearch) } private fun init() { @@ -94,10 +137,11 @@ class AppWiseIpLogsActivity : uiCtx { this.appInfo = appInfo - b.awlAppDetailName.text = appName(packages.count()) + val appName = appName(packages.count()) + updateAppNameInSearchHint(appName) displayIcon( Utilities.getIcon(this, appInfo.packageName, appInfo.appName), - b.awlAppDetailIcon + b.awlAppDetailIcon1 ) } } @@ -114,10 +158,10 @@ class AppWiseIpLogsActivity : val mb: MaterialButton = b.toggleGroup.findViewById(checkedId) if (isChecked) { selectToggleBtnUi(mb) - val tcValue = (mb.tag as String).toIntOrNull() ?: 0 + val tcValue = (mb.tag as String).toIntOrNull() ?: 2 // "2" tag is for 7 days val timeCategory = AppConnectionsViewModel.TimeCategory.fromValue(tcValue) - ?: AppConnectionsViewModel.TimeCategory.ONE_HOUR + ?: AppConnectionsViewModel.TimeCategory.SEVEN_DAYS networkLogsViewModel.timeCategoryChanged(timeCategory, isDomain = false) return@OnButtonCheckedListener } @@ -138,7 +182,7 @@ class AppWiseIpLogsActivity : } private fun highlightToggleBtn() { - val timeCategory = "0" // default is 1 hours, "0" tag is 1 hours + val timeCategory = "2" // default is 7 days, "2" tag is for 7 days val btn = b.toggleGroup.findViewWithTag(timeCategory) btn.isChecked = true selectToggleBtnUi(btn) @@ -171,57 +215,155 @@ class AppWiseIpLogsActivity : b.awlRecyclerConnection.setHasFixedSize(true) layoutManager = LinearLayoutManager(this) b.awlRecyclerConnection.layoutManager = layoutManager - val recyclerAdapter = AppWiseIpsAdapter(this, this, uid) + val recyclerAdapter = AppWiseIpsAdapter(this, this, uid, isRethink) networkLogsViewModel.appIpLogs.observe(this) { recyclerAdapter.submitData(this.lifecycle, it) } b.awlRecyclerConnection.adapter = recyclerAdapter + + /*recyclerAdapter.addLoadStateListener { + if (it.append.endOfPaginationReached) { + if (networkLogsViewModel.filterQuery.isNotEmpty()) { + return@addLoadStateListener + } + if (recyclerAdapter.itemCount < 1) { + showNoRulesUi() + hideRulesUi() + } else { + hideNoRulesUi() + showRulesUi() + } + } else { + hideNoRulesUi() + showRulesUi() + } + }*/ } - private fun observeNetworkLogSize() { - networkLogsViewModel.getConnectionsCount(uid).observe(this) { - if (it == null) return@observe + private fun setAsnAdapter() { + Logger.v(LOG_TAG_UI, "setAsnAdapter: uid: $uid, isRethink: $isRethink") + networkLogsViewModel.setUid(uid) + b.awlRecyclerConnection.setHasFixedSize(true) + layoutManager = LinearLayoutManager(this) + b.awlRecyclerConnection.layoutManager = layoutManager + val recyclerAdapter = AppWiseIpsAdapter(this, this, uid, isRethink, isAsn = true) + networkLogsViewModel.asnLogs.observe(this) { + recyclerAdapter.submitData(this.lifecycle, it) + } + b.awlRecyclerConnection.adapter = recyclerAdapter - if (it <= 0) { - showNoRulesUi() - hideRulesUi() - return@observe + /*recyclerAdapter.addLoadStateListener { + if (it.append.endOfPaginationReached) { + if (networkLogsViewModel.filterQuery.isNotEmpty()) { + return@addLoadStateListener + } + if (recyclerAdapter.itemCount < 1) { + showNoRulesUi() + hideRulesUi() + } else { + hideNoRulesUi() + showRulesUi() + } + } else { + hideNoRulesUi() + showRulesUi() } + }*/ + } - hideNoRulesUi() - showRulesUi() + private fun setRethinkAdapter() { + networkLogsViewModel.setUid(uid) + b.awlRecyclerConnection.setHasFixedSize(true) + layoutManager = LinearLayoutManager(this) + b.awlRecyclerConnection.layoutManager = layoutManager + val recyclerAdapter = AppWiseIpsAdapter(this, this, uid, isRethink) + networkLogsViewModel.rinrIpLogs.observe(this) { + recyclerAdapter.submitData(this.lifecycle, it) + } + b.awlRecyclerConnection.adapter = recyclerAdapter + + /*recyclerAdapter.addLoadStateListener { + if (it.append.endOfPaginationReached) { + if (networkLogsViewModel.filterQuery.isNotEmpty()) { + return@addLoadStateListener + } + if (recyclerAdapter.itemCount < 1) { + showNoRulesUi() + hideRulesUi() + } else { + hideNoRulesUi() + showRulesUi() + } + } else { + hideNoRulesUi() + showRulesUi() + } + }*/ + } + + private fun updateAppNameInSearchHint(appName: String) { + val appNameTruncated = appName.substring(0, appName.length.coerceAtMost(10)) + val hint = if (isAsn) { + getString( + R.string.two_argument_colon, + appNameTruncated, + getString(R.string.search_universal_asn) + ) + } else { + getString( + R.string.two_argument_colon, + appNameTruncated, + getString(R.string.search_universal_ips) + ) } + b.awlSearch.queryHint = hint + b.awlSearch.findViewById(androidx.appcompat.R.id.search_src_text).textSize = + 14f + return } - private fun showNoRulesUi() { - b.awlNoRulesRl.visibility = android.view.View.VISIBLE + // commenting for now, see if we can remove this later + /*private fun showNoRulesUi() { + b.awlNoRulesRl.visibility = View.VISIBLE } private fun hideRulesUi() { - b.awlCardViewTop.visibility = android.view.View.GONE - b.awlAppDetailRl.visibility = android.view.View.GONE - b.awlRecyclerConnection.visibility = android.view.View.GONE + b.awlCardViewTop.visibility = View.GONE + b.awlRecyclerConnection.visibility = View.GONE } private fun hideNoRulesUi() { - b.awlNoRulesRl.visibility = android.view.View.GONE + b.awlNoRulesRl.visibility = View.GONE } private fun showRulesUi() { - b.awlCardViewTop.visibility = android.view.View.VISIBLE - b.awlAppDetailRl.visibility = android.view.View.VISIBLE - b.awlRecyclerConnection.visibility = android.view.View.VISIBLE - } + b.awlCardViewTop.visibility = View.VISIBLE + b.awlRecyclerConnection.visibility = View.VISIBLE + }*/ override fun onQueryTextSubmit(query: String): Boolean { - networkLogsViewModel.setFilter(query, AppConnectionsViewModel.FilterType.IP) + Utilities.delay(QUERY_TEXT_DELAY, lifecycleScope) { + if (!this.isFinishing) { + val type = if (isAsn) { + AppConnectionsViewModel.FilterType.ASN + } else { + AppConnectionsViewModel.FilterType.IP + } + networkLogsViewModel.setFilter(query, type) + } + } return true } override fun onQueryTextChange(query: String): Boolean { - Utilities.delay(500, lifecycleScope) { + Utilities.delay(QUERY_TEXT_DELAY, lifecycleScope) { if (!this.isFinishing) { - networkLogsViewModel.setFilter(query, AppConnectionsViewModel.FilterType.IP) + val type = if (isAsn) { + AppConnectionsViewModel.FilterType.ASN + } else { + AppConnectionsViewModel.FilterType.IP + } + networkLogsViewModel.setFilter(query, type) } } return true @@ -239,7 +381,9 @@ class AppWiseIpLogsActivity : } private fun deleteAppLogs() { - io { connectionTrackerRepository.clearLogsByUid(uid) } + io { + networkLogsViewModel.deleteLogs(uid) + } } private fun io(f: suspend () -> Unit): Job { diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/CheckoutActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/CheckoutActivity.kt index 48b1e93fb..5328036eb 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/activity/CheckoutActivity.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/activity/CheckoutActivity.kt @@ -26,10 +26,14 @@ import android.text.style.ForegroundColorSpan import android.view.View import android.widget.RadioButton import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.core.view.updatePadding import androidx.lifecycle.lifecycleScope import androidx.work.WorkInfo import androidx.work.WorkManager -import backend.Backend +import com.celzero.firestack.backend.Backend import by.kirich1409.viewbindingdelegate.viewBinding import com.celzero.bravedns.R import com.celzero.bravedns.databinding.ActivityCheckoutProxyBinding @@ -38,6 +42,10 @@ import com.celzero.bravedns.service.PersistentState import com.celzero.bravedns.service.TcpProxyHelper import com.celzero.bravedns.util.Themes import com.celzero.bravedns.util.UIUtils.fetchColor +import com.celzero.bravedns.util.Utilities.isAtleastO_MR1 +import com.celzero.bravedns.util.Utilities.isAtleastQ +import com.celzero.bravedns.util.Utilities.togs +import com.celzero.bravedns.util.Utilities.tos import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -50,9 +58,6 @@ import java.util.UUID class CheckoutActivity : AppCompatActivity(R.layout.activity_checkout_proxy) { private val b by viewBinding(ActivityCheckoutProxyBinding::bind) private val persistentState by inject() - // lateinit var paymentSheet: PaymentSheet - // lateinit var customerConfig: PaymentSheet.CustomerConfiguration - lateinit var paymentIntentClientSecret: String companion object { private const val TOKEN_LENGTH = 32 @@ -66,6 +71,12 @@ class CheckoutActivity : AppCompatActivity(R.layout.activity_checkout_proxy) { override fun onCreate(savedInstanceState: Bundle?) { setTheme(Themes.getCurrentTheme(isDarkThemeOn(), persistentState.theme)) super.onCreate(savedInstanceState) + + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } init() setupClickListeners() @@ -176,7 +187,7 @@ class CheckoutActivity : AppCompatActivity(R.layout.activity_checkout_proxy) { try { val key = TcpProxyHelper.getPublicKey() Logger.d(Logger.LOG_TAG_PROXY, "Public Key: $key") - val encryptedKey = Backend.newPipKey(key, "") + val encryptedKey = Backend.newPipKey(key.togs(), "".togs()) val blind = encryptedKey.blind() Logger.d(Logger.LOG_TAG_PROXY, "Blind: $blind") val path = @@ -187,7 +198,7 @@ class CheckoutActivity : AppCompatActivity(R.layout.activity_checkout_proxy) { File.separator + TcpProxyHelper.PIP_KEY_FILE_NAME ) - EncryptedFileManager.writeTcpConfig(this, blind, TcpProxyHelper.PIP_KEY_FILE_NAME) + EncryptedFileManager.writeTcpConfig(this, blind.tos() ?: "", TcpProxyHelper.PIP_KEY_FILE_NAME) val content = EncryptedFileManager.read(this, path) Logger.d(Logger.LOG_TAG_PROXY, "Content: $content") } catch (e: Exception) { @@ -200,21 +211,18 @@ class CheckoutActivity : AppCompatActivity(R.layout.activity_checkout_proxy) { b.paymentSuccessButton.setOnClickListener { val intent = Intent(this, TcpProxyMainActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED startActivity(intent) finish() } b.restoreButton.setOnClickListener { val intent = Intent(this, TcpProxyMainActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED startActivity(intent) finish() } b.paymentFailedButton.setOnClickListener { val intent = Intent(this, TcpProxyMainActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED startActivity(intent) finish() } diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/ConfigureOtherDnsActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/ConfigureOtherDnsActivity.kt index 8e8d49bd6..99c6631f1 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/activity/ConfigureOtherDnsActivity.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/activity/ConfigureOtherDnsActivity.kt @@ -19,6 +19,7 @@ import android.content.Context import android.content.res.Configuration import android.os.Bundle import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowInsetsControllerCompat import androidx.fragment.app.Fragment import androidx.viewpager2.adapter.FragmentStateAdapter import by.kirich1409.viewbindingdelegate.viewBinding @@ -31,6 +32,7 @@ import com.celzero.bravedns.ui.fragment.DoTListFragment import com.celzero.bravedns.ui.fragment.DohListFragment import com.celzero.bravedns.ui.fragment.ODoHListFragment import com.celzero.bravedns.util.Themes +import com.celzero.bravedns.util.Utilities.isAtleastQ import com.google.android.material.tabs.TabLayoutMediator import org.koin.android.ext.android.inject @@ -79,6 +81,12 @@ class ConfigureOtherDnsActivity : AppCompatActivity(R.layout.activity_configure_ override fun onCreate(savedInstanceState: Bundle?) { setTheme(Themes.getCurrentTheme(isDarkThemeOn(), persistentState.theme)) super.onCreate(savedInstanceState) + + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } dnsType = intent.getIntExtra(DNS_TYPE, dnsType) init() } diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/ConfigureRethinkBasicActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/ConfigureRethinkBasicActivity.kt index 1cecdfc13..016f73f9e 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/activity/ConfigureRethinkBasicActivity.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/activity/ConfigureRethinkBasicActivity.kt @@ -15,10 +15,13 @@ */ package com.celzero.bravedns.ui.activity +import Logger +import Logger.LOG_TAG_UI import android.content.Context import android.content.res.Configuration import android.os.Bundle import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowInsetsControllerCompat import androidx.fragment.app.Fragment import com.celzero.bravedns.R import com.celzero.bravedns.service.PersistentState @@ -27,6 +30,7 @@ import com.celzero.bravedns.ui.fragment.RethinkBlocklistFragment import com.celzero.bravedns.ui.fragment.RethinkListFragment import com.celzero.bravedns.util.Constants import com.celzero.bravedns.util.Themes +import com.celzero.bravedns.util.Utilities.isAtleastQ import org.koin.android.ext.android.inject class ConfigureRethinkBasicActivity : AppCompatActivity(R.layout.fragment_rethink_basic) { @@ -49,21 +53,22 @@ class ConfigureRethinkBasicActivity : AppCompatActivity(R.layout.fragment_rethin override fun onCreate(savedInstanceState: Bundle?) { setTheme(Themes.getCurrentTheme(isDarkThemeOn(), persistentState.theme)) super.onCreate(savedInstanceState) + + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + + Logger.v(LOG_TAG_UI, "init configure rethink base activity") val type = intent.getIntExtra(INTENT, FragmentLoader.REMOTE.ordinal) val fl = FragmentLoader.entries[type] val fragment = fragment(fl) - - if (savedInstanceState == null) { - supportFragmentManager - .beginTransaction() - .add(R.id.root_container, fragment, fragment.javaClass.simpleName) - .commit() - } else { - supportFragmentManager - .beginTransaction() - .replace(R.id.root_container, fragment, fragment.javaClass.simpleName) - .commit() - } + Logger.i(LOG_TAG_UI, "loading fragment: ${fragment.javaClass.simpleName}") + supportFragmentManager + .beginTransaction() + .replace(R.id.root_container, fragment, fragment.javaClass.simpleName) + .commit() } private fun fragment(fl: FragmentLoader): Fragment { diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/ConsoleLogActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/ConsoleLogActivity.kt new file mode 100644 index 000000000..92d37c640 --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/ui/activity/ConsoleLogActivity.kt @@ -0,0 +1,352 @@ +/* + * Copyright 2024 RethinkDNS and its authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.celzero.bravedns.ui.activity + +import Logger +import Logger.LOG_TAG_BUG_REPORT +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInfo +import android.content.res.Configuration +import android.net.Uri +import android.os.Bundle +import android.view.View +import android.view.inputmethod.InputMethodManager +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.FileProvider +import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.work.WorkInfo +import androidx.work.WorkManager +import by.kirich1409.viewbindingdelegate.viewBinding +import com.celzero.bravedns.R +import com.celzero.bravedns.adapter.ConsoleLogAdapter +import com.celzero.bravedns.database.ConsoleLogRepository +import com.celzero.bravedns.databinding.ActivityConsoleLogBinding +import com.celzero.bravedns.scheduler.WorkScheduler +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.util.Constants +import com.celzero.bravedns.util.Themes +import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.isAtleastQ +import com.celzero.bravedns.util.Utilities.showToastUiCentered +import com.celzero.bravedns.viewmodel.ConsoleLogViewModel +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koin.android.ext.android.inject +import java.io.File + +class ConsoleLogActivity : AppCompatActivity(R.layout.activity_console_log), androidx.appcompat.widget.SearchView.OnQueryTextListener { + + private val b by viewBinding(ActivityConsoleLogBinding::bind) + private var layoutManager: RecyclerView.LayoutManager? = null + private val persistentState by inject() + + private val viewModel by inject() + private val consoleLogRepository by inject() + private val workScheduler by inject() + + companion object { + private const val FILE_NAME = "rethink_app_logs_" + private const val FILE_EXTENSION = ".zip" + private const val QUERY_TEXT_DELAY: Long = 1000 + } + + private fun Context.isDarkThemeOn(): Boolean { + return resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == + Configuration.UI_MODE_NIGHT_YES + } + + override fun onCreate(savedInstanceState: Bundle?) { + setTheme(Themes.getCurrentTheme(isDarkThemeOn(), persistentState.theme)) + super.onCreate(savedInstanceState) + + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + initView() + setupClickListener() + } + + override fun onResume() { + super.onResume() + // fix for #1939, OEM-specific bug, especially on heavily customized Android + // some ROMs kill or freeze the keyboard/IME process to save memory or battery, + // causing SearchView to stop receiving input events + // this is a workaround to restart the IME process + b.searchView.setQuery("", false) + b.searchView.clearFocus() + + val imm = this.getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager + imm.restartInput(b.searchView) + } + + private fun initView() { + setAdapter() + // update the text view with the time since logs are available + io { + val sinceTime = viewModel.sinceTime() + if (sinceTime == 0L) return@io + + val since = Utilities.convertLongToTime(sinceTime, Constants.TIME_FORMAT_3) + uiCtx { + val desc = getString(R.string.console_log_desc) + val sinceTxt = getString(R.string.logs_card_duration, since) + val descWithTime = getString(R.string.two_argument_space, desc, sinceTxt) + b.consoleLogInfoText.text = descWithTime + } + } + b.searchView.setOnQueryTextListener(this) + } + + var recyclerAdapter: ConsoleLogAdapter? = null + + private fun setAdapter() { + b.consoleLogList.setHasFixedSize(true) + layoutManager = LinearLayoutManager(this@ConsoleLogActivity) + b.consoleLogList.layoutManager = layoutManager + recyclerAdapter = ConsoleLogAdapter(this) + b.consoleLogList.adapter = recyclerAdapter + viewModel.setLogLevel(Logger.uiLogLevel) + observeLog() + } + + private fun observeLog() { + viewModel.logs.observe(this) { l -> + lifecycleScope.launch { + delay(500) + recyclerAdapter?.submitData(l) + } + } + } + + private fun setupClickListener() { + + b.consoleLogShare.setOnClickListener { + val filePath = makeConsoleLogFile() + if (filePath == null) { + showFileCreationErrorToast() + return@setOnClickListener + } + handleShareLogs(filePath) + } + + b.fabShareLog.setOnClickListener { + val filePath = makeConsoleLogFile() + if (filePath == null) { + showFileCreationErrorToast() + return@setOnClickListener + } + handleShareLogs(filePath) + } + + b.consoleLogDelete.setOnClickListener { + MaterialAlertDialogBuilder(this) + .setTitle(getString(R.string.console_log_delete_title)) + .setMessage(getString(R.string.console_log_delete_desc)) + .setPositiveButton(getString(R.string.lbl_delete)) { _, _ -> + io { + Logger.i(LOG_TAG_BUG_REPORT, "deleting all console logs") + consoleLogRepository.deleteAllLogs() + uiCtx { + showToastUiCentered( + this, + getString(R.string.console_log_delete_toast), + Toast.LENGTH_SHORT + ) + finish() + } + } + } + .setNegativeButton(getString(R.string.lbl_cancel)) { dialog, _ -> + dialog.dismiss() + } + .show() + } + + b.searchFilterIcon.setOnClickListener { + showFilterDialog() + } + } + + private fun showFilterDialog() { + // show dialog with level filter + val builder = MaterialAlertDialogBuilder(this) + builder.setTitle(getString(R.string.console_log_title)) + val items = Logger.LoggerLevel.entries + val checkedItem = Logger.uiLogLevel.toInt() + builder.setSingleChoiceItems( + items.map { it.name }.toTypedArray(), + checkedItem + ) { _, which -> + Logger.uiLogLevel = items[which].id + viewModel.setLogLevel(which.toLong()) + if (which < Logger.LoggerLevel.ERROR.id) { + consoleLogRepository.setStartTimestamp(System.currentTimeMillis()) + } + Logger.i(LOG_TAG_BUG_REPORT, "Log level set to ${items[which].name}") + } + builder.setCancelable(true) + builder.setPositiveButton(getString(R.string.fapps_info_dialog_positive_btn)) { dialogInterface, _ -> + dialogInterface.dismiss() + } + builder.setNeutralButton(getString(R.string.lbl_cancel)) { dialogInterface, _ -> + dialogInterface.dismiss() + } + val alertDialog: AlertDialog = builder.create() + alertDialog.setCancelable(true) + alertDialog.show() + } + + private fun handleShareLogs(filePath: String) { + if (WorkScheduler.isWorkRunning(this, WorkScheduler.CONSOLE_LOG_SAVE_JOB_TAG)) return + + workScheduler.scheduleConsoleLogSaveJob(filePath) + showLogGenerationProgressUi() + + val workManager = WorkManager.getInstance(this.applicationContext) + workManager.getWorkInfosByTagLiveData(WorkScheduler.CONSOLE_LOG_SAVE_JOB_TAG).observe( + this + ) { workInfoList -> + val workInfo = workInfoList?.getOrNull(0) ?: return@observe + Logger.i( + Logger.LOG_TAG_SCHEDULER, + "WorkManager state: ${workInfo.state} for ${WorkScheduler.CONSOLE_LOG_SAVE_JOB_TAG}" + ) + if (WorkInfo.State.SUCCEEDED == workInfo.state) { + onSuccess() + shareZipFileViaEmail(filePath) + workManager.pruneWork() + } else if ( + WorkInfo.State.CANCELLED == workInfo.state || + WorkInfo.State.FAILED == workInfo.state + ) { + onFailure() + workManager.pruneWork() + workManager.cancelAllWorkByTag(WorkScheduler.CONSOLE_LOG_SAVE_JOB_TAG) + } else { // state == blocked, queued, or running + // no-op + } + } + } + + private fun onSuccess() { + // show success message + Logger.i(LOG_TAG_BUG_REPORT, "created logs successfully") + b.consoleLogProgressBar.visibility = View.GONE + Toast.makeText(this, getString(R.string.config_add_success_toast), Toast.LENGTH_LONG).show() + } + + private fun onFailure() { + // show failure message + Logger.i(LOG_TAG_BUG_REPORT, "failed to create logs") + b.consoleLogProgressBar.visibility = View.GONE + Toast.makeText( + this, + getString(R.string.download_update_dialog_failure_title), + Toast.LENGTH_LONG + ) + .show() + } + + private fun showLogGenerationProgressUi() { + // show progress dialog or progress bar + Logger.i(LOG_TAG_BUG_REPORT, "showing log generation progress UI") + b.consoleLogProgressBar.visibility = View.VISIBLE + } + + private fun shareZipFileViaEmail(filePath: String) { + val file = File(filePath) + // Get the URI of the file using FileProvider + val uri: Uri = FileProvider.getUriForFile(this, "${this.packageName}.provider", file) + + // Create the intent + val intent = + Intent(Intent.ACTION_SEND).apply { + type = "application/zip" + putExtra(Intent.EXTRA_SUBJECT, "Log File") + putExtra(Intent.EXTRA_TEXT, "Attached is the log file for RethinkDNS.") + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + // start the email app + startActivity(Intent.createChooser(intent, "Send email...")) + } + + private fun makeConsoleLogFile(): String? { + return try { + val appVersion = getVersionName() + "_" + System.currentTimeMillis() + // create file in filesdir, no need to check for permissions + val dir = filesDir.canonicalPath + File.separator + val fileName: String = FILE_NAME + appVersion + FILE_EXTENSION + val file = File(dir, fileName) + if (!file.exists()) { + file.createNewFile() + } + return file.absolutePath + } catch (e: Exception) { + Logger.w(LOG_TAG_BUG_REPORT, "error creating log file, ${e.message}") + null + } + } + + private fun getVersionName(): String { + val pInfo: PackageInfo? = + Utilities.getPackageMetadata(this.packageManager, this.packageName) + return pInfo?.versionName ?: "" + } + + private fun showFileCreationErrorToast() { + // show toast message + showToastUiCentered( + this, + getString(R.string.error_loading_log_file), + Toast.LENGTH_SHORT + ) + } + + private fun io(f: suspend () -> Unit) { + lifecycleScope.launch(Dispatchers.IO) { f() } + } + + private suspend fun uiCtx(f: () -> Unit) { + withContext(Dispatchers.Main) { f() } + } + + override fun onQueryTextSubmit(query: String): Boolean { + Utilities.delay(QUERY_TEXT_DELAY, lifecycleScope) { + viewModel.setFilter(query) + } + return true + } + + override fun onQueryTextChange(query: String): Boolean { + Utilities.delay(QUERY_TEXT_DELAY, lifecycleScope) { + viewModel.setFilter(query) + } + return true + } +} diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/CustomRulesActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/CustomRulesActivity.kt index 2dd747baf..23081c3ed 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/activity/CustomRulesActivity.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/activity/CustomRulesActivity.kt @@ -21,6 +21,7 @@ import android.content.Intent import android.content.res.Configuration import android.os.Bundle import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowInsetsControllerCompat import androidx.fragment.app.Fragment import androidx.viewpager2.adapter.FragmentStateAdapter import by.kirich1409.viewbindingdelegate.viewBinding @@ -33,6 +34,7 @@ import com.celzero.bravedns.ui.fragment.CustomDomainFragment import com.celzero.bravedns.ui.fragment.CustomIpFragment import com.celzero.bravedns.util.Constants import com.celzero.bravedns.util.Themes.Companion.getCurrentTheme +import com.celzero.bravedns.util.Utilities.isAtleastQ import com.google.android.material.tabs.TabLayoutMediator import org.koin.android.ext.android.inject @@ -76,6 +78,13 @@ class CustomRulesActivity : AppCompatActivity(R.layout.activity_custom_rules) { override fun onCreate(savedInstanceState: Bundle?) { setTheme(getCurrentTheme(isDarkThemeOn(), persistentState.theme)) super.onCreate(savedInstanceState) + + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + fragmentIndex = intent.getIntExtra(Constants.VIEW_PAGER_SCREEN_TO_LOAD, 0) rulesType = intent.getIntExtra(INTENT_RULES, RULES.APP_SPECIFIC_RULES.type) uid = intent.getIntExtra(Constants.INTENT_UID, Constants.UID_EVERYBODY) diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/DetailedStatisticsActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/DetailedStatisticsActivity.kt index 9bf8f7c8b..eda4bf85e 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/activity/DetailedStatisticsActivity.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/activity/DetailedStatisticsActivity.kt @@ -1,3 +1,18 @@ +/* + * Copyright 2024 RethinkDNS and its authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package com.celzero.bravedns.ui.activity import android.content.Context @@ -6,8 +21,8 @@ import android.content.res.Configuration.UI_MODE_NIGHT_YES import android.os.Bundle import android.view.View import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.LiveData -import androidx.lifecycle.lifecycleScope import androidx.paging.PagingData import by.kirich1409.viewbindingdelegate.viewBinding import com.celzero.bravedns.R @@ -15,16 +30,13 @@ import com.celzero.bravedns.adapter.SummaryStatisticsAdapter import com.celzero.bravedns.data.AppConfig import com.celzero.bravedns.data.AppConnection import com.celzero.bravedns.databinding.ActivityDetailedStatisticsBinding -import com.celzero.bravedns.service.FirewallManager import com.celzero.bravedns.service.PersistentState import com.celzero.bravedns.ui.fragment.SummaryStatisticsFragment import com.celzero.bravedns.util.CustomLinearLayoutManager import com.celzero.bravedns.util.Themes.Companion.getCurrentTheme +import com.celzero.bravedns.util.Utilities.isAtleastQ import com.celzero.bravedns.viewmodel.DetailedStatisticsViewModel import com.celzero.bravedns.viewmodel.SummaryStatisticsViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel @@ -49,6 +61,12 @@ class DetailedStatisticsActivity : AppCompatActivity(R.layout.activity_detailed_ setTheme(getCurrentTheme(isDarkThemeOn(), persistentState.theme)) super.onCreate(savedInstanceState) + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + val type = intent.getIntExtra( INTENT_TYPE, @@ -59,11 +77,16 @@ class DetailedStatisticsActivity : AppCompatActivity(R.layout.activity_detailed_ SummaryStatisticsViewModel.TimeCategory.fromValue(tc) ?: SummaryStatisticsViewModel.TimeCategory.ONE_HOUR val statType = SummaryStatisticsFragment.SummaryStatisticsType.getType(type) - setSubTitle(timeCategory) + setSubTitle(statType, timeCategory) setRecyclerView(statType, timeCategory) } - private fun setSubTitle(timeCategory: SummaryStatisticsViewModel.TimeCategory) { + private fun setSubTitle(type: SummaryStatisticsFragment.SummaryStatisticsType, timeCategory: SummaryStatisticsViewModel.TimeCategory) { + if (type == SummaryStatisticsFragment.SummaryStatisticsType.TOP_ACTIVE_CONNS) { + b.dsaSubtitle.visibility = View.GONE + return + } + b.dsaSubtitle.text = when (timeCategory) { SummaryStatisticsViewModel.TimeCategory.ONE_HOUR -> { @@ -103,6 +126,7 @@ class DetailedStatisticsActivity : AppCompatActivity(R.layout.activity_detailed_ b.dsaRecycler.layoutManager = layoutManager val recyclerAdapter = SummaryStatisticsAdapter(this, persistentState, appConfig, type) + recyclerAdapter.setTimeCategory(timeCategory) viewModel.timeCategoryChanged(timeCategory) handleStatType(type).observe(this) { recyclerAdapter.submitData(this.lifecycle, it) } @@ -113,7 +137,13 @@ class DetailedStatisticsActivity : AppCompatActivity(R.layout.activity_detailed_ if (recyclerAdapter.itemCount < 1) { b.dsaRecycler.visibility = View.GONE b.dsaNoDataRl.visibility = View.VISIBLE + } else { + b.dsaRecycler.visibility = View.VISIBLE + b.dsaNoDataRl.visibility = View.GONE } + } else { + b.dsaRecycler.visibility = View.VISIBLE + b.dsaNoDataRl.visibility = View.GONE } } b.dsaRecycler.adapter = recyclerAdapter @@ -122,11 +152,12 @@ class DetailedStatisticsActivity : AppCompatActivity(R.layout.activity_detailed_ private fun handleStatType( type: SummaryStatisticsFragment.SummaryStatisticsType ): LiveData> { - io { - val isAppBypassed = FirewallManager.isAnyAppBypassesDns() - uiCtx { viewModel.setData(type, isAppBypassed) } - } + viewModel.setData(type) return when (type) { + SummaryStatisticsFragment.SummaryStatisticsType.TOP_ACTIVE_CONNS -> { + b.dsaTitle.text = getString(R.string.top_active_conns) + viewModel.getAllActiveConns + } SummaryStatisticsFragment.SummaryStatisticsType.MOST_CONNECTED_APPS -> { b.dsaTitle.text = getString(R.string.ssv_app_network_activity_heading) viewModel.getAllAllowedAppNetworkActivity @@ -135,6 +166,14 @@ class DetailedStatisticsActivity : AppCompatActivity(R.layout.activity_detailed_ b.dsaTitle.text = getString(R.string.ssv_app_blocked_heading) viewModel.getAllBlockedAppNetworkActivity } + SummaryStatisticsFragment.SummaryStatisticsType.MOST_CONNECTED_ASN -> { + b.dsaTitle.text = getString(R.string.most_contacted_asn) + viewModel.getAllAllowedAsn + } + SummaryStatisticsFragment.SummaryStatisticsType.MOST_BLOCKED_ASN -> { + b.dsaTitle.text = getString(R.string.most_blocked_asn) + viewModel.getAllBlockedAsn + } SummaryStatisticsFragment.SummaryStatisticsType.MOST_CONTACTED_DOMAINS -> { b.dsaTitle.text = getString(R.string.ssv_most_contacted_domain_heading) viewModel.getAllContactedDomains @@ -155,18 +194,6 @@ class DetailedStatisticsActivity : AppCompatActivity(R.layout.activity_detailed_ b.dsaTitle.text = getString(R.string.ssv_most_contacted_countries_heading) viewModel.getAllContactedCountries } - SummaryStatisticsFragment.SummaryStatisticsType.MOST_BLOCKED_COUNTRIES -> { - b.dsaTitle.text = getString(R.string.ssv_most_blocked_countries_heading) - viewModel.getAllBlockedCountries - } } } - - private fun io(f: suspend () -> Unit) { - lifecycleScope.launch(Dispatchers.IO) { f() } - } - - private suspend fun uiCtx(f: suspend () -> Unit) { - withContext(Dispatchers.Main) { f() } - } } diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/DnsDetailActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/DnsDetailActivity.kt index 812d24709..a2228d02d 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/activity/DnsDetailActivity.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/activity/DnsDetailActivity.kt @@ -19,6 +19,7 @@ import android.content.Context import android.content.res.Configuration import android.os.Bundle import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowInsetsControllerCompat import androidx.fragment.app.Fragment import androidx.viewpager2.adapter.FragmentStateAdapter import by.kirich1409.viewbindingdelegate.viewBinding @@ -27,6 +28,7 @@ import com.celzero.bravedns.databinding.ActivityDnsDetailBinding import com.celzero.bravedns.service.PersistentState import com.celzero.bravedns.ui.fragment.DnsSettingsFragment import com.celzero.bravedns.util.Themes.Companion.getCurrentTheme +import com.celzero.bravedns.util.Utilities.isAtleastQ import com.google.android.material.tabs.TabLayoutMediator import org.koin.android.ext.android.inject @@ -40,7 +42,7 @@ class DnsDetailActivity : AppCompatActivity(R.layout.activity_dns_detail) { companion object { fun getCount(): Int { - return values().count() + return entries.toTypedArray().count() } } } @@ -48,6 +50,13 @@ class DnsDetailActivity : AppCompatActivity(R.layout.activity_dns_detail) { override fun onCreate(savedInstanceState: Bundle?) { setTheme(getCurrentTheme(isDarkThemeOn(), persistentState.theme)) super.onCreate(savedInstanceState) + + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + init() } diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/DnsListActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/DnsListActivity.kt index 464c1c6a0..d295c7555 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/activity/DnsListActivity.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/activity/DnsListActivity.kt @@ -20,8 +20,8 @@ import android.content.Intent import android.content.res.Configuration import android.os.Bundle import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.lifecycleScope -import backend.Backend import by.kirich1409.viewbindingdelegate.viewBinding import com.celzero.bravedns.R import com.celzero.bravedns.data.AppConfig @@ -32,6 +32,8 @@ import com.celzero.bravedns.service.VpnController import com.celzero.bravedns.util.Themes import com.celzero.bravedns.util.UIUtils import com.celzero.bravedns.util.UIUtils.fetchColor +import com.celzero.bravedns.util.Utilities.isAtleastQ +import com.celzero.firestack.backend.Backend import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -46,6 +48,13 @@ class DnsListActivity : AppCompatActivity(R.layout.activity_other_dns_list) { override fun onCreate(savedInstanceState: Bundle?) { setTheme(Themes.getCurrentTheme(isDarkThemeOn(), persistentState.theme)) super.onCreate(savedInstanceState) + + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + setupClickListeners() } diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/DomainConnectionsActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/DomainConnectionsActivity.kt new file mode 100644 index 000000000..c5914eae3 --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/ui/activity/DomainConnectionsActivity.kt @@ -0,0 +1,173 @@ +/* + * Copyright 2024 RethinkDNS and its authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.celzero.bravedns.ui.activity + +import android.content.Context +import android.content.res.Configuration +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import android.os.Bundle +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowInsetsControllerCompat +import by.kirich1409.viewbindingdelegate.viewBinding +import com.celzero.bravedns.R +import com.celzero.bravedns.adapter.DomainConnectionsAdapter +import com.celzero.bravedns.databinding.ActivityDomainConnectionsBinding +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.util.CustomLinearLayoutManager +import com.celzero.bravedns.util.Themes.Companion.getCurrentTheme +import com.celzero.bravedns.util.UIUtils.getCountryNameFromFlag +import com.celzero.bravedns.util.Utilities.isAtleastQ +import com.celzero.bravedns.viewmodel.DomainConnectionsViewModel +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel + +class DomainConnectionsActivity : AppCompatActivity(R.layout.activity_domain_connections){ + private val b by viewBinding(ActivityDomainConnectionsBinding::bind) + private val persistentState by inject() + private val viewModel by viewModel() + + private var type: InputType = InputType.DOMAIN + + private fun Context.isDarkThemeOn(): Boolean { + return resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == + UI_MODE_NIGHT_YES + } + + companion object { + const val INTENT_EXTRA_TYPE = "TYPE" + const val INTENT_EXTRA_FLAG = "FLAG" + const val INTENT_EXTRA_DOMAIN = "DOMAIN" + const val INTENT_EXTRA_ASN = "ASN" + const val INTENT_EXTRA_IS_BLOCKED = "IS_BLOCKED" + const val INTENT_EXTRA_TIME_CATEGORY = "TIME_CATEGORY" + } + + enum class InputType(val type: Int) { + DOMAIN(0), FLAG(1), ASN(2); + } + + override fun onCreate(savedInstanceState: Bundle?) { + setTheme(getCurrentTheme(isDarkThemeOn(), persistentState.theme)) + super.onCreate(savedInstanceState) + + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + + val t = intent.getIntExtra(INTENT_EXTRA_TYPE, 0) + type = InputType.entries.toTypedArray()[t] + when (type) { + InputType.DOMAIN -> { + val domain = intent.getStringExtra(INTENT_EXTRA_DOMAIN) ?: "" + viewModel.setDomain(domain) + b.dcTitle.text = domain + } + InputType.FLAG -> { + val flag = intent.getStringExtra(INTENT_EXTRA_FLAG) ?: "" + viewModel.setFlag(flag) + b.dcTitle.text = getString(R.string.two_argument_space, flag, getCountryNameFromFlag(flag)) + } + InputType.ASN -> { + val asn = intent.getStringExtra(INTENT_EXTRA_ASN) ?: "" + val isBlocked = intent.getBooleanExtra(INTENT_EXTRA_IS_BLOCKED, false) + viewModel.setAsn(asn, isBlocked) + b.dcTitle.text = asn + } + } + val tc = intent.getIntExtra(INTENT_EXTRA_TIME_CATEGORY, 0) + val timeCategory = + DomainConnectionsViewModel.TimeCategory.fromValue(tc) + ?: DomainConnectionsViewModel.TimeCategory.ONE_HOUR + setSubTitle(timeCategory) + viewModel.timeCategoryChanged(timeCategory) + setRecyclerView() + } + + private fun setSubTitle(timeCategory: DomainConnectionsViewModel.TimeCategory) { + b.dcSubtitle.text = + when (timeCategory) { + DomainConnectionsViewModel.TimeCategory.ONE_HOUR -> { + getString( + R.string.three_argument, + getString(R.string.lbl_last), + getString(R.string.numeric_one), + getString(R.string.lbl_hour) + ) + } + + DomainConnectionsViewModel.TimeCategory.TWENTY_FOUR_HOUR -> { + getString( + R.string.three_argument, + getString(R.string.lbl_last), + getString(R.string.numeric_twenty_four), + getString(R.string.lbl_hour) + ) + } + + DomainConnectionsViewModel.TimeCategory.SEVEN_DAYS -> { + getString( + R.string.three_argument, + getString(R.string.lbl_last), + getString(R.string.numeric_seven), + getString(R.string.lbl_day) + ) + } + } + } + + private fun setRecyclerView() { + b.dcRecycler.setHasFixedSize(true) + val layoutManager = CustomLinearLayoutManager(this) + b.dcRecycler.layoutManager = layoutManager + + val recyclerAdapter = DomainConnectionsAdapter(this, type) + + val liveData = when (type) { + InputType.DOMAIN -> { + viewModel.domainConnectionList + } + InputType.FLAG -> { + viewModel.flagConnectionList + } + InputType.ASN -> { + viewModel.asnConnectionList + } + } + + liveData.observe(this) { recyclerAdapter.submitData(this.lifecycle, it) } + + // remove the view if there is no data + recyclerAdapter.addLoadStateListener { + if (it.append.endOfPaginationReached) { + if (recyclerAdapter.itemCount < 1) { + b.dcRecycler.visibility = View.GONE + b.dcNoDataRl.visibility = View.VISIBLE + liveData.removeObservers(this) + } else { + b.dcRecycler.visibility = View.VISIBLE + b.dcNoDataRl.visibility = View.GONE + } + } else { + b.dcRecycler.visibility = View.VISIBLE + b.dcNoDataRl.visibility = View.GONE + } + } + b.dcRecycler.adapter = recyclerAdapter + } +} \ No newline at end of file diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/FirewallActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/FirewallActivity.kt index 6660856fb..9a66f8515 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/activity/FirewallActivity.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/activity/FirewallActivity.kt @@ -21,6 +21,7 @@ import android.content.Intent import android.content.res.Configuration import android.os.Bundle import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowInsetsControllerCompat import androidx.fragment.app.Fragment import androidx.viewpager2.adapter.FragmentStateAdapter import by.kirich1409.viewbindingdelegate.viewBinding @@ -31,6 +32,7 @@ import com.celzero.bravedns.service.PersistentState import com.celzero.bravedns.service.VpnController import com.celzero.bravedns.ui.fragment.FirewallSettingsFragment import com.celzero.bravedns.util.Themes.Companion.getCurrentTheme +import com.celzero.bravedns.util.Utilities.isAtleastQ import com.google.android.material.tabs.TabLayoutMediator import org.koin.android.ext.android.inject @@ -43,7 +45,7 @@ class FirewallActivity : AppCompatActivity(R.layout.activity_firewall) { companion object { fun getCount(): Int { - return values().count() + return entries.count() } } } @@ -51,6 +53,12 @@ class FirewallActivity : AppCompatActivity(R.layout.activity_firewall) { override fun onCreate(savedInstanceState: Bundle?) { setTheme(getCurrentTheme(isDarkThemeOn(), persistentState.theme)) super.onCreate(savedInstanceState) + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + init() } diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/MiscSettingsActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/MiscSettingsActivity.kt index 69d4f7585..3cbebd7bc 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/activity/MiscSettingsActivity.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/activity/MiscSettingsActivity.kt @@ -16,6 +16,7 @@ package com.celzero.bravedns.ui.activity import Logger +import Logger.LOG_TAG_APP_OPS import Logger.LOG_TAG_UI import Logger.LOG_TAG_VPN import Logger.updateConfigLevel @@ -43,8 +44,8 @@ import androidx.appcompat.app.AppCompatDelegate import androidx.biometric.BiometricManager import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat -import androidx.core.net.toUri import androidx.core.os.LocaleListCompat +import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.lifecycleScope import by.kirich1409.viewbindingdelegate.viewBinding import com.celzero.bravedns.R @@ -54,13 +55,16 @@ import com.celzero.bravedns.databinding.ActivityMiscSettingsBinding import com.celzero.bravedns.net.go.GoVpnAdapter import com.celzero.bravedns.service.PersistentState import com.celzero.bravedns.ui.bottomsheet.BackupRestoreBottomSheet +import com.celzero.bravedns.util.BackgroundAccessibilityService import com.celzero.bravedns.util.Constants import com.celzero.bravedns.util.NotificationActionType import com.celzero.bravedns.util.PcapMode import com.celzero.bravedns.util.Themes import com.celzero.bravedns.util.Themes.Companion.getCurrentTheme +import com.celzero.bravedns.util.UIUtils.openUrl import com.celzero.bravedns.util.Utilities import com.celzero.bravedns.util.Utilities.delay +import com.celzero.bravedns.util.Utilities.isAtleastQ import com.celzero.bravedns.util.Utilities.isAtleastR import com.celzero.bravedns.util.Utilities.isAtleastT import com.celzero.bravedns.util.Utilities.isFdroidFlavour @@ -75,6 +79,11 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import java.util.concurrent.TimeUnit +import androidx.core.net.toUri +import com.celzero.bravedns.ui.LauncherSwitcher +import com.celzero.bravedns.ui.activity.AppLockActivity.Companion.APP_LOCK_ALIAS +import com.celzero.bravedns.ui.activity.AppLockActivity.Companion.HOME_ALIAS + class MiscSettingsActivity : AppCompatActivity(R.layout.activity_misc_settings) { private val b by viewBinding(ActivityMiscSettingsBinding::bind) @@ -84,6 +93,23 @@ class MiscSettingsActivity : AppCompatActivity(R.layout.activity_misc_settings) private lateinit var notificationPermissionResult: ActivityResultLauncher + enum class BioMetricType(val action: Int, val mins: Long) { + OFF(0, -1L), + IMMEDIATE(1, 0L), + FIVE_MIN(2, 5L), + FIFTEEN_MIN(3, 15L); + + companion object { + fun fromValue(action: Int): BioMetricType { + return entries.firstOrNull { it.action == action } ?: OFF + } + } + + fun enabled(): Boolean { + return this != OFF + } + } + companion object { private const val SCHEME_PACKAGE = "package" private const val STORAGE_PERMISSION_CODE = 23 @@ -92,6 +118,13 @@ class MiscSettingsActivity : AppCompatActivity(R.layout.activity_misc_settings) override fun onCreate(savedInstanceState: Bundle?) { setTheme(getCurrentTheme(isDarkThemeOn(), persistentState.theme)) super.onCreate(savedInstanceState) + + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + registerForActivityResult() initView() setupClickListeners() @@ -105,10 +138,10 @@ class MiscSettingsActivity : AppCompatActivity(R.layout.activity_misc_settings) // enable logs b.settingsActivityEnableLogsSwitch.isChecked = persistentState.logsEnabled - // Auto start app after reboot - b.settingsActivityAutoStartSwitch.isChecked = persistentState.prefAutoStartBootUp // check for app updates b.settingsActivityCheckUpdateSwitch.isChecked = persistentState.checkForAppUpdate + // camera and microphone access + b.settingsMicCamAccessSwitch.isChecked = persistentState.micCamAccess // for app locale (default system/user selected locale) if (isAtleastT()) { @@ -127,14 +160,20 @@ class MiscSettingsActivity : AppCompatActivity(R.layout.activity_misc_settings) .canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS ) { - b.settingsBiometricSwitch.isChecked = persistentState.biometricAuth + val txt = + getString( + R.string.two_argument_colon, + getString(R.string.settings_biometric_desc), + BioMetricType.fromValue(persistentState.biometricAuthType).name + ) + b.settingsBiometricDesc.text = txt } else { b.settingsBiometricRl.visibility = View.GONE } + displayPcapUi() displayAppThemeUi() displayNotificationActionUi() - displayPcapUi() } private fun displayNotificationActionUi() { @@ -168,21 +207,186 @@ class MiscSettingsActivity : AppCompatActivity(R.layout.activity_misc_settings) } } - private fun displayPcapUi() { - b.settingsActivityPcapRl.isEnabled = true - when (PcapMode.getPcapType(persistentState.pcapMode)) { - PcapMode.NONE -> { - b.settingsActivityPcapDesc.text = getString(R.string.settings_pcap_dialog_option_1) + private fun showFileCreationErrorToast() { + showToastUiCentered(this, getString(R.string.pcap_failure_toast), Toast.LENGTH_SHORT) + // reset the pcap mode to NONE + persistentState.pcapMode = PcapMode.NONE.id + displayPcapUi() + } + + private fun makePcapFile(): File? { + return try { + val sdf = SimpleDateFormat(BackupHelper.BACKUP_FILE_NAME_DATETIME, Locale.ROOT) + // create folder in DOWNLOADS + val dir = + if (isAtleastR()) { + val downloadsDir = + Environment.getExternalStoragePublicDirectory( + Environment.DIRECTORY_DOWNLOADS + ) + // create folder in DOWNLOADS/Rethink + File(downloadsDir, Constants.PCAP_FOLDER_NAME) + } else { + val downloadsDir = Environment.getExternalStorageDirectory() + // create folder in DOWNLOADS/Rethink + File(downloadsDir, Constants.PCAP_FOLDER_NAME) + } + if (!dir.exists()) { + dir.mkdirs() + } + // filename format (rethink_pcap_.pcap) + val pcapFileName: String = + Constants.PCAP_FILE_NAME_PART + sdf.format(Date()) + Constants.PCAP_FILE_EXTENSION + val file = File(dir, pcapFileName) + // just in case, create the parent dir if it doesn't exist + if (file.parentFile?.exists() != true) file.parentFile?.mkdirs() + // create the file if it doesn't exist + if (!file.exists()) { + file.createNewFile() } + file + } catch (e: Exception) { + Logger.e(LOG_TAG_VPN, "error creating pcap file ${e.message}", e) + null + } + } - PcapMode.LOGCAT -> { - b.settingsActivityPcapDesc.text = getString(R.string.settings_pcap_dialog_option_2) + private fun createAndSetPcapFile() { + // check for storage permissions + if (!checkStoragePermissions()) { + // request for storage permissions + Logger.i(LOG_TAG_VPN, "requesting for storage permissions") + requestForStoragePermissions() + return + } + + Logger.i(LOG_TAG_VPN, "storage permission granted, creating pcap file") + try { + val file = makePcapFile() + if (file == null) { + showFileCreationErrorToast() + return } + // set the file descriptor instead of fd, need to close the file descriptor + // after tunnel creation + appConfig.setPcap(PcapMode.EXTERNAL_FILE.id, file.absolutePath) + } catch (ignored: Exception) { + showFileCreationErrorToast() + } + } - PcapMode.EXTERNAL_FILE -> { - b.settingsActivityPcapDesc.text = getString(R.string.settings_pcap_dialog_option_3) + private val storageActivityResultLauncher: ActivityResultLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (isAtleastR()) { + // version 11 (R) or above + if (Environment.isExternalStorageManager()) { + createAndSetPcapFile() + } else { + showFileCreationErrorToast() + } + } else { + // below ver 11 (R), the permission is handled via onRequestPermissionsResult + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray, + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == STORAGE_PERMISSION_CODE) { + if (grantResults.isNotEmpty()) { + val write = grantResults[0] == PackageManager.PERMISSION_GRANTED + val read = grantResults[1] == PackageManager.PERMISSION_GRANTED + if (read && write) { + createAndSetPcapFile() + } else { + showFileCreationErrorToast() + } + } + } + } + + private fun requestForStoragePermissions() { + // version 11 (R) or above + if (isAtleastR()) { + try { + val intent = Intent() + intent.action = Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION + val uri = Uri.fromParts(SCHEME_PACKAGE, this.packageName, null) + intent.data = uri + storageActivityResultLauncher.launch(intent) + } catch (ignored: Exception) { + val intent = Intent() + intent.action = Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION + storageActivityResultLauncher.launch(intent) + } + } else { + // below version 11 + ActivityCompat.requestPermissions( + this, + arrayOf( + Manifest.permission.WRITE_EXTERNAL_STORAGE, + Manifest.permission.READ_EXTERNAL_STORAGE, + ), + STORAGE_PERMISSION_CODE, + ) + } + } + + + private fun checkStoragePermissions(): Boolean { + return if (isAtleastR()) { + // version 11 (R) or above + Environment.isExternalStorageManager() + } else { + // below version 11 + val write = + ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) + val read = + ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) + read == PackageManager.PERMISSION_GRANTED && write == PackageManager.PERMISSION_GRANTED + } + } + + private fun showPcapOptionsDialog() { + val alertBuilder = MaterialAlertDialogBuilder(this) + alertBuilder.setTitle(getString(R.string.settings_pcap_dialog_title)) + val items = + arrayOf( + getString(R.string.settings_pcap_dialog_option_1), + getString(R.string.settings_pcap_dialog_option_2), + getString(R.string.settings_pcap_dialog_option_3), + ) + val checkedItem = persistentState.pcapMode + alertBuilder.setSingleChoiceItems(items, checkedItem) { dialog, which -> + dialog.dismiss() + if (persistentState.pcapMode == which) { + return@setSingleChoiceItems + } + + when (PcapMode.getPcapType(which)) { + PcapMode.NONE -> { + b.settingsActivityPcapDesc.text = + getString(R.string.settings_pcap_dialog_option_1) + appConfig.setPcap(PcapMode.NONE.id) + } + + PcapMode.LOGCAT -> { + b.settingsActivityPcapDesc.text = + getString(R.string.settings_pcap_dialog_option_2) + appConfig.setPcap(PcapMode.LOGCAT.id, PcapMode.ENABLE_PCAP_LOGCAT) + } + + PcapMode.EXTERNAL_FILE -> { + b.settingsActivityPcapDesc.text = + getString(R.string.settings_pcap_dialog_option_3) + createAndSetPcapFile() + } } } + alertBuilder.create().show() } private fun displayAppThemeUi() { @@ -212,6 +416,30 @@ class MiscSettingsActivity : AppCompatActivity(R.layout.activity_misc_settings) ) } + Themes.TRUE_BLACK.id -> { + b.genSettingsThemeDesc.text = + getString( + R.string.settings_selected_theme, + getString(R.string.settings_theme_dialog_themes_4) + ) + } + + Themes.LIGHT_PLUS.id -> { + b.genSettingsThemeDesc.text = + getString( + R.string.settings_selected_theme, + getString(R.string.settings_theme_dialog_themes_5) + ) + } + + Themes.DARK_PLUS.id -> { + b.genSettingsThemeDesc.text = + getString( + R.string.settings_selected_theme, + getString(R.string.settings_theme_dialog_themes_6) + ) + } + else -> { b.genSettingsThemeDesc.text = getString( @@ -222,6 +450,23 @@ class MiscSettingsActivity : AppCompatActivity(R.layout.activity_misc_settings) } } + private fun displayPcapUi() { + b.settingsActivityPcapRl.isEnabled = true + when (PcapMode.getPcapType(persistentState.pcapMode)) { + PcapMode.NONE -> { + b.settingsActivityPcapDesc.text = getString(R.string.settings_pcap_dialog_option_1) + } + + PcapMode.LOGCAT -> { + b.settingsActivityPcapDesc.text = getString(R.string.settings_pcap_dialog_option_2) + } + + PcapMode.EXTERNAL_FILE -> { + b.settingsActivityPcapDesc.text = getString(R.string.settings_pcap_dialog_option_3) + } + } + } + private fun setupClickListeners() { b.settingsActivityEnableLogsRl.setOnClickListener { b.settingsActivityEnableLogsSwitch.isChecked = @@ -233,16 +478,6 @@ class MiscSettingsActivity : AppCompatActivity(R.layout.activity_misc_settings) persistentState.logsEnabled = b } - b.settingsActivityAutoStartRl.setOnClickListener { - b.settingsActivityAutoStartSwitch.isChecked = - !b.settingsActivityAutoStartSwitch.isChecked - } - - b.settingsActivityAutoStartSwitch.setOnCheckedChangeListener { _: CompoundButton, b: Boolean - -> - persistentState.prefAutoStartBootUp = b - } - b.settingsActivityCheckUpdateRl.setOnClickListener { b.settingsActivityCheckUpdateSwitch.isChecked = !b.settingsActivityCheckUpdateSwitch.isChecked @@ -253,16 +488,24 @@ class MiscSettingsActivity : AppCompatActivity(R.layout.activity_misc_settings) persistentState.checkForAppUpdate = b } - b.settingsActivityThemeRl.setOnClickListener { - enableAfterDelay(500, b.settingsActivityThemeRl) - showThemeDialog() - } b.settingsGoLogRl.setOnClickListener { enableAfterDelay(500, b.settingsGoLogRl) showGoLoggerDialog() } + + b.settingsActivityPcapRl.setOnClickListener { + enableAfterDelay(TimeUnit.SECONDS.toMillis(1L), b.settingsActivityPcapRl) + showPcapOptionsDialog() + } + + + b.settingsActivityThemeRl.setOnClickListener { + enableAfterDelay(500, b.settingsActivityThemeRl) + showThemeDialog() + } + // Ideally this property should be part of VPN category / section. // As of now the VPN section will be disabled when the // VPN is in lockdown mode. @@ -282,11 +525,6 @@ class MiscSettingsActivity : AppCompatActivity(R.layout.activity_misc_settings) persistentState.persistentNotification = b } - b.settingsActivityPcapRl.setOnClickListener { - enableAfterDelay(TimeUnit.SECONDS.toMillis(1L), b.settingsActivityPcapRl) - showPcapOptionsDialog() - } - b.settingsActivityImportExportRl.setOnClickListener { invokeImportExport() } b.settingsActivityAppNotificationSwitch.setOnClickListener { @@ -298,16 +536,213 @@ class MiscSettingsActivity : AppCompatActivity(R.layout.activity_misc_settings) b.settingsLocaleRl.setOnClickListener { invokeChangeLocaleDialog() } b.settingsBiometricRl.setOnClickListener { - b.settingsBiometricSwitch.isChecked = !b.settingsBiometricSwitch.isChecked + showBiometricDialog() } - b.settingsBiometricSwitch.setOnCheckedChangeListener { _: CompoundButton, checked: Boolean - -> - persistentState.biometricAuth = checked - // Reset the biometric auth time - persistentState.biometricAuthTime = Constants.INIT_TIME_MS + b.settingsBiometricImg.setOnClickListener { + showBiometricDialog() } - } + + + b.settingsMicCamAccessRl.setOnClickListener { + b.settingsMicCamAccessSwitch.isChecked = !b.settingsMicCamAccessSwitch.isChecked + } + + b.settingsMicCamAccessSwitch.setOnCheckedChangeListener { _: CompoundButton, checked: Boolean + -> + if (!checked) { + b.settingsMicCamAccessSwitch.isChecked = false + persistentState.micCamAccess = false + return@setOnCheckedChangeListener + } + + // check for the permission and enable the switch + handleAccessibilityPermission() + } + } + + private fun showGoLoggerDialog() { + // show dialog with logger options, change log level in GoVpnAdapter based on selection + val alertBuilder = MaterialAlertDialogBuilder(this) + alertBuilder.setTitle(getString(R.string.settings_go_log_heading)) + val items = + arrayOf( + getString(R.string.settings_gologger_dialog_option_0), + getString(R.string.settings_gologger_dialog_option_1), + getString(R.string.settings_gologger_dialog_option_2), + getString(R.string.settings_gologger_dialog_option_3), + getString(R.string.settings_gologger_dialog_option_4), + getString(R.string.settings_gologger_dialog_option_5), + getString(R.string.settings_gologger_dialog_option_6), + getString(R.string.settings_gologger_dialog_option_7), + ) + val checkedItem = persistentState.goLoggerLevel.toInt() + alertBuilder.setSingleChoiceItems(items, checkedItem) { dialog, which -> + dialog.dismiss() + if (checkedItem == which) { + return@setSingleChoiceItems + } + + persistentState.goLoggerLevel = which.toLong() + GoVpnAdapter.setLogLevel(persistentState.goLoggerLevel.toInt()) + updateConfigLevel(persistentState.goLoggerLevel) + } + alertBuilder.create().show() + } + + + private fun showBiometricDialog() { + val alertBuilder = MaterialAlertDialogBuilder(this) + alertBuilder.setTitle(getString(R.string.settings_biometric_dialog_heading)) + // show an list of options disable, enable immediate, ask after 5 min, ask after 15 min + val item0 = getString(R.string.settings_biometric_dialog_option_0) + val item1 = getString(R.string.settings_biometric_dialog_option_1) + val item2 = getString(R.string.settings_biometric_dialog_option_2) + val item3 = getString(R.string.settings_biometric_dialog_option_3) + val items = arrayOf(item0, item1, item2, item3) + + val checkedItem = persistentState.biometricAuthType + alertBuilder.setSingleChoiceItems(items, checkedItem) { dialog, which -> + dialog.dismiss() + if (persistentState.biometricAuthType == which) { + return@setSingleChoiceItems + } + val bioMetricType = BioMetricType.fromValue(which) + when (bioMetricType) { + BioMetricType.OFF -> { + val txt = getString(R.string.two_argument_colon, getString(R.string.settings_biometric_desc), + getString(R.string.settings_biometric_dialog_option_0)) + b.settingsBiometricDesc.text = txt + persistentState.biometricAuthType = BioMetricType.OFF.action + persistentState.biometricAuthTime = Constants.INIT_TIME_MS + } + + BioMetricType.IMMEDIATE -> { + val txt = getString(R.string.two_argument_colon, getString(R.string.settings_biometric_desc), + getString(R.string.settings_biometric_dialog_option_1)) + b.settingsBiometricDesc.text = txt + persistentState.biometricAuthType = BioMetricType.IMMEDIATE.action + persistentState.biometricAuthTime = Constants.INIT_TIME_MS + } + + BioMetricType.FIVE_MIN -> { + val txt = getString(R.string.two_argument_colon, getString(R.string.settings_biometric_desc), + getString(R.string.settings_biometric_dialog_option_2)) + b.settingsBiometricDesc.text = txt + persistentState.biometricAuthType = BioMetricType.FIVE_MIN.action + persistentState.biometricAuthTime = System.currentTimeMillis() + } + + BioMetricType.FIFTEEN_MIN -> { + val txt = getString(R.string.two_argument_colon, getString(R.string.settings_biometric_desc), + getString(R.string.settings_biometric_dialog_option_3)) + b.settingsBiometricDesc.text = txt + persistentState.biometricAuthType = BioMetricType.FIFTEEN_MIN.action + persistentState.biometricAuthTime = System.currentTimeMillis() + } + } + if (bioMetricType.enabled()) { + Logger.i(LOG_TAG_UI, "biometric auth enabled, switching to app lock alias") + LauncherSwitcher.switchLauncherAlias(applicationContext, APP_LOCK_ALIAS, HOME_ALIAS) + } else { + Logger.i(LOG_TAG_UI, "biometric auth disabled, switching to home alias") + LauncherSwitcher.switchLauncherAlias(applicationContext, HOME_ALIAS, APP_LOCK_ALIAS) + } + } + alertBuilder.create().show() + } + + private fun handleAccessibilityPermission() { + try { + val isAccessibilityServiceRunning = + Utilities.isAccessibilityServiceEnabled( + this, + BackgroundAccessibilityService::class.java + ) + val isAccessibilityServiceEnabled = + Utilities.isAccessibilityServiceEnabledViaSettingsSecure( + this, + BackgroundAccessibilityService::class.java + ) + val isAccessibilityServiceFunctional = + isAccessibilityServiceRunning && isAccessibilityServiceEnabled + + if (isAccessibilityServiceFunctional) { + persistentState.micCamAccess = true + b.settingsMicCamAccessSwitch.isChecked = true + return + } + + showPermissionAlert() + b.settingsMicCamAccessSwitch.isChecked = false + persistentState.micCamAccess = false + } catch (e: PackageManager.NameNotFoundException) { + Logger.e(LOG_TAG_APP_OPS, "error checking usage stats permission ${e.message}", e) + return + } + } + + private fun checkMicCamAccessRule() { + if (!persistentState.micCamAccess) return + + val running = + Utilities.isAccessibilityServiceEnabled( + this, + BackgroundAccessibilityService::class.java + ) + val enabled = + Utilities.isAccessibilityServiceEnabledViaSettingsSecure( + this, + BackgroundAccessibilityService::class.java + ) + + Logger.d(LOG_TAG_APP_OPS, "cam/mic access - running: $running, enabled: $enabled") + + val isAccessibilityServiceFunctional = running && enabled + + if (!isAccessibilityServiceFunctional) { + persistentState.micCamAccess = false + b.settingsMicCamAccessSwitch.isChecked = false + showToastUiCentered( + this, + getString(R.string.accessibility_failure_toast), + Toast.LENGTH_SHORT + ) + return + } + + if (running) { + b.settingsMicCamAccessSwitch.isChecked = persistentState.micCamAccess + return + } + } + + private fun showPermissionAlert() { + val builder = MaterialAlertDialogBuilder(this) + builder.setTitle(R.string.alert_permission_accessibility) + builder.setMessage(R.string.alert_firewall_accessibility_explanation) + builder.setPositiveButton(getString(R.string.univ_accessibility_dialog_positive)) { _, _ -> + openAccessibilitySettings() + } + builder.setNegativeButton(getString(R.string.univ_accessibility_dialog_negative)) { _, _ -> + } + builder.setCancelable(false) + builder.create().show() + } + + private fun openAccessibilitySettings() { + try { + val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) + startActivity(intent) + } catch (e: ActivityNotFoundException) { + showToastUiCentered( + this, + getString(R.string.alert_firewall_accessibility_exception), + Toast.LENGTH_SHORT + ) + Logger.e(LOG_TAG_APP_OPS, "Failure accessing accessibility settings: ${e.message}", e) + } + } private fun invokeChangeLocaleDialog() { val alertBuilder = MaterialAlertDialogBuilder(this) @@ -325,31 +760,16 @@ class MiscSettingsActivity : AppCompatActivity(R.layout.activity_misc_settings) dialog.dismiss() val item = items[which] // https://developer.android.com/guide/topics/resources/app-languages#app-language-settings - val locale = languages.getOrDefault(item, "en-US") - AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(locale)) + val locale = Locale.forLanguageTag(languages.getOrDefault(item, "en-US")) + AppCompatDelegate.setApplicationLocales(LocaleListCompat.create(locale)) } - alertBuilder.setNeutralButton(getString(R.string.settings_locale_dialog_neutral)) { dialog, - _ -> + alertBuilder.setNeutralButton(getString(R.string.settings_locale_dialog_neutral)) { dialog, _ -> dialog.dismiss() - openActionViewIntent(getString(R.string.about_translate_link).toUri()) + openUrl(this, getString(R.string.about_translate_link)) } alertBuilder.create().show() } - private fun openActionViewIntent(uri: Uri) { - val intent = Intent(Intent.ACTION_VIEW, uri) - try { - startActivity(intent) - } catch (e: ActivityNotFoundException) { - showToastUiCentered( - this, - getString(R.string.intent_launch_error, intent.data), - Toast.LENGTH_SHORT - ) - Logger.w(LOG_TAG_UI, "activity not found ${e.message}", e) - } - } - // read the list of supported languages from locale_config.xml private fun getLocalesFromLocaleConfig(): LocaleListCompat { val tagsList = mutableListOf() @@ -405,7 +825,9 @@ class MiscSettingsActivity : AppCompatActivity(R.layout.activity_misc_settings) getString(R.string.settings_theme_dialog_themes_1), getString(R.string.settings_theme_dialog_themes_2), getString(R.string.settings_theme_dialog_themes_3), - getString(R.string.settings_theme_dialog_themes_4) + getString(R.string.settings_theme_dialog_themes_4), + getString(R.string.settings_theme_dialog_themes_5), + getString(R.string.settings_theme_dialog_themes_6) ) val checkedItem = persistentState.theme alertBuilder.setSingleChoiceItems(items, checkedItem) { dialog, which -> @@ -423,96 +845,31 @@ class MiscSettingsActivity : AppCompatActivity(R.layout.activity_misc_settings) setThemeRecreate(R.style.AppThemeWhite) } } - Themes.LIGHT.id -> { setThemeRecreate(R.style.AppThemeWhite) } - Themes.DARK.id -> { setThemeRecreate(R.style.AppTheme) } - Themes.TRUE_BLACK.id -> { setThemeRecreate(R.style.AppThemeTrueBlack) } + Themes.LIGHT_PLUS.id -> { + setThemeRecreate(R.style.AppThemeWhitePlus) + } + Themes.DARK_PLUS.id -> { + setThemeRecreate(R.style.AppThemeTrueBlackPlus) + } } } alertBuilder.create().show() } - private fun showGoLoggerDialog() { - // show dialog with logger options, change log level in GoVpnAdapter based on selection - val alertBuilder = MaterialAlertDialogBuilder(this) - alertBuilder.setTitle(getString(R.string.settings_go_log_heading)) - val items = - arrayOf( - getString(R.string.settings_gologger_dialog_option_0), - getString(R.string.settings_gologger_dialog_option_1), - getString(R.string.settings_gologger_dialog_option_2), - getString(R.string.settings_gologger_dialog_option_3), - getString(R.string.settings_gologger_dialog_option_4), - getString(R.string.settings_gologger_dialog_option_5), - getString(R.string.settings_gologger_dialog_option_6), - getString(R.string.settings_gologger_dialog_option_7) - ) - val checkedItem = persistentState.goLoggerLevel.toInt() - alertBuilder.setSingleChoiceItems(items, checkedItem) { dialog, which -> - dialog.dismiss() - if (checkedItem == which) { - return@setSingleChoiceItems - } - - persistentState.goLoggerLevel = which.toLong() - GoVpnAdapter.setLogLevel(persistentState.goLoggerLevel.toInt()) - updateConfigLevel(persistentState.goLoggerLevel) - } - alertBuilder.create().show() - } - private fun setThemeRecreate(theme: Int) { setTheme(theme) recreate() } - private fun showPcapOptionsDialog() { - val alertBuilder = MaterialAlertDialogBuilder(this) - alertBuilder.setTitle(getString(R.string.settings_pcap_dialog_title)) - val items = - arrayOf( - getString(R.string.settings_pcap_dialog_option_1), - getString(R.string.settings_pcap_dialog_option_2), - getString(R.string.settings_pcap_dialog_option_3) - ) - val checkedItem = persistentState.pcapMode - alertBuilder.setSingleChoiceItems(items, checkedItem) { dialog, which -> - dialog.dismiss() - if (persistentState.pcapMode == which) { - return@setSingleChoiceItems - } - - when (PcapMode.getPcapType(which)) { - PcapMode.NONE -> { - b.settingsActivityPcapDesc.text = - getString(R.string.settings_pcap_dialog_option_1) - appConfig.setPcap(PcapMode.NONE.id) - } - - PcapMode.LOGCAT -> { - b.settingsActivityPcapDesc.text = - getString(R.string.settings_pcap_dialog_option_2) - appConfig.setPcap(PcapMode.LOGCAT.id, PcapMode.ENABLE_PCAP_LOGCAT) - } - - PcapMode.EXTERNAL_FILE -> { - b.settingsActivityPcapDesc.text = - getString(R.string.settings_pcap_dialog_option_3) - createAndSetPcapFile() - } - } - } - alertBuilder.create().show() - } - private fun showNotificationActionDialog() { val alertBuilder = MaterialAlertDialogBuilder(this) alertBuilder.setTitle(getString(R.string.settings_notification_dialog_title)) @@ -567,6 +924,7 @@ class MiscSettingsActivity : AppCompatActivity(R.layout.activity_misc_settings) super.onResume() // app notification permission android 13 showEnableNotificationSettingIfNeeded() + checkMicCamAccessRule() } private fun registerForActivityResult() { @@ -590,148 +948,6 @@ class MiscSettingsActivity : AppCompatActivity(R.layout.activity_misc_settings) } } - private val storageActivityResultLauncher: ActivityResultLauncher = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (isAtleastR()) { - // version 11 (R) or above - if (Environment.isExternalStorageManager()) { - createAndSetPcapFile() - } else { - showFileCreationErrorToast() - } - } else { - // below ver 11 (R), the permission is handled via onRequestPermissionsResult - } - } - - override fun onRequestPermissionsResult( - requestCode: Int, - permissions: Array, - grantResults: IntArray - ) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults) - if (requestCode == STORAGE_PERMISSION_CODE) { - if (grantResults.isNotEmpty()) { - val write = grantResults[0] == PackageManager.PERMISSION_GRANTED - val read = grantResults[1] == PackageManager.PERMISSION_GRANTED - if (read && write) { - createAndSetPcapFile() - } else { - showFileCreationErrorToast() - } - } - } - } - - private fun requestForStoragePermissions() { - // version 11 (R) or above - if (isAtleastR()) { - try { - val intent = Intent() - intent.action = Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION - val uri = Uri.fromParts(SCHEME_PACKAGE, this.packageName, null) - intent.data = uri - storageActivityResultLauncher.launch(intent) - } catch (e: Exception) { - val intent = Intent() - intent.action = Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION - storageActivityResultLauncher.launch(intent) - } - } else { - // below version 11 - ActivityCompat.requestPermissions( - this, - arrayOf( - Manifest.permission.WRITE_EXTERNAL_STORAGE, - Manifest.permission.READ_EXTERNAL_STORAGE - ), - STORAGE_PERMISSION_CODE - ) - } - } - - private fun showFileCreationErrorToast() { - showToastUiCentered(this, getString(R.string.pcap_failure_toast), Toast.LENGTH_SHORT) - // reset the pcap mode to NONE - persistentState.pcapMode = PcapMode.NONE.id - displayPcapUi() - } - - private fun createAndSetPcapFile() { - // check for storage permissions - if (!checkStoragePermissions()) { - // request for storage permissions - Logger.i(LOG_TAG_VPN, "requesting for storage permissions") - requestForStoragePermissions() - return - } - - Logger.i(LOG_TAG_VPN, "storage permission granted, creating pcap file") - try { - val file = makePcapFile() - if (file == null) { - showFileCreationErrorToast() - return - } - // set the file descriptor instead of fd, need to close the file descriptor - // after tunnel creation - appConfig.setPcap(PcapMode.EXTERNAL_FILE.id, file.absolutePath) - } catch (e: Exception) { - showFileCreationErrorToast() - } - } - - private fun makePcapFile(): File? { - return try { - val sdf = SimpleDateFormat(BackupHelper.BACKUP_FILE_NAME_DATETIME, Locale.ROOT) - // create folder in DOWNLOADS - val dir = - if (isAtleastR()) { - val downloadsDir = - Environment.getExternalStoragePublicDirectory( - Environment.DIRECTORY_DOWNLOADS - ) - // create folder in DOWNLOADS/Rethink - File(downloadsDir, Constants.PCAP_FOLDER_NAME) - } else { - val downloadsDir = Environment.getExternalStorageDirectory() - // create folder in DOWNLOADS/Rethink - File(downloadsDir, Constants.PCAP_FOLDER_NAME) - } - if (!dir.exists()) { - dir.mkdirs() - } - // filename format (rethink_pcap_.pcap) - val pcapFileName: String = - Constants.PCAP_FILE_NAME_PART + sdf.format(Date()) + Constants.PCAP_FILE_EXTENSION - val file = File(dir, pcapFileName) - // just in case, create the parent dir if it doesn't exist - if (file.parentFile?.exists() != true) file.parentFile?.mkdirs() - // create the file if it doesn't exist - if (!file.exists()) { - file.createNewFile() - } - file - } catch (e: Exception) { - Logger.e(LOG_TAG_VPN, "error creating pcap file ${e.message}", e) - null - } - } - - private fun checkStoragePermissions(): Boolean { - return if (isAtleastR()) { - // version 11 (R) or above - Environment.isExternalStorageManager() - } else { - // below version 11 - val write = - ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) - val read = - ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) - read == PackageManager.PERMISSION_GRANTED && write == PackageManager.PERMISSION_GRANTED - } - } - private fun invokeNotificationPermission() { if (!isAtleastT()) { // notification permission is needed for version 13 or above @@ -757,7 +973,7 @@ class MiscSettingsActivity : AppCompatActivity(R.layout.activity_misc_settings) } else { intent.action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS intent.addCategory(Intent.CATEGORY_DEFAULT) - intent.data = Uri.parse("package:$packageName") + intent.data = "$SCHEME_PACKAGE:$packageName".toUri() } startActivity(intent) } catch (e: ActivityNotFoundException) { diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/NetworkLogsActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/NetworkLogsActivity.kt index 929ddb68f..a533d41a1 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/activity/NetworkLogsActivity.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/activity/NetworkLogsActivity.kt @@ -21,6 +21,7 @@ import android.content.Intent import android.content.res.Configuration import android.os.Bundle import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowInsetsControllerCompat import androidx.fragment.app.Fragment import androidx.viewpager2.adapter.FragmentStateAdapter import by.kirich1409.viewbindingdelegate.viewBinding @@ -30,11 +31,14 @@ import com.celzero.bravedns.databinding.ActivityNetworkLogsBinding import com.celzero.bravedns.service.BraveVPNService import com.celzero.bravedns.service.PersistentState import com.celzero.bravedns.service.VpnController +import com.celzero.bravedns.ui.activity.UniversalFirewallSettingsActivity.Companion.RULES_SEARCH_ID import com.celzero.bravedns.ui.fragment.ConnectionTrackerFragment import com.celzero.bravedns.ui.fragment.DnsLogFragment import com.celzero.bravedns.ui.fragment.RethinkLogFragment +import com.celzero.bravedns.ui.fragment.WgNwStatsFragment import com.celzero.bravedns.util.Constants import com.celzero.bravedns.util.Themes.Companion.getCurrentTheme +import com.celzero.bravedns.util.Utilities.isAtleastQ import com.google.android.material.tabs.TabLayoutMediator import org.koin.android.ext.android.inject @@ -42,6 +46,11 @@ class NetworkLogsActivity : AppCompatActivity(R.layout.activity_network_logs) { private val b by viewBinding(ActivityNetworkLogsBinding::bind) private var fragmentIndex = 0 private var searchParam = "" + // to handle search navigation from universal firewall, to show only the search results + // of the selected universal rule, show only network logs tab + private var isUnivNavigated = false + // to handle the wireguard connections + private var isWireGuardLogs = false private val persistentState by inject() private val appConfig by inject() @@ -49,14 +58,31 @@ class NetworkLogsActivity : AppCompatActivity(R.layout.activity_network_logs) { enum class Tabs(val screen: Int) { NETWORK_LOGS(0), DNS_LOGS(1), - RETHINK_LOGS(2) + RETHINK_LOGS(2), + WIREGUARD_STATS(3) + } + + companion object { + const val RULES_SEARCH_ID_WIREGUARD = "W:" } override fun onCreate(savedInstanceState: Bundle?) { setTheme(getCurrentTheme(isDarkThemeOn(), persistentState.theme)) super.onCreate(savedInstanceState) + + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + fragmentIndex = intent.getIntExtra(Constants.VIEW_PAGER_SCREEN_TO_LOAD, 0) searchParam = intent.getStringExtra(Constants.SEARCH_QUERY) ?: "" + if (searchParam.contains(RULES_SEARCH_ID)) { + isUnivNavigated = true + } else if(searchParam.contains(RULES_SEARCH_ID_WIREGUARD)) { + isWireGuardLogs = true + } init() } @@ -87,9 +113,25 @@ class NetworkLogsActivity : AppCompatActivity(R.layout.activity_network_logs) { b.logsActViewpager.setCurrentItem(fragmentIndex, false) observeAppState() + + b.appLogs.setOnClickListener { + openConsoleLogActivity() + } + } + + private fun openConsoleLogActivity() { + val intent = Intent(this, ConsoleLogActivity::class.java) + startActivity(intent) } private fun getCount(): Int { + if (isUnivNavigated) { + return 1 + } + if (isWireGuardLogs) { + return 2 + } + var count = 0 if (persistentState.routeRethinkInRethink) { count = 1 @@ -102,6 +144,16 @@ class NetworkLogsActivity : AppCompatActivity(R.layout.activity_network_logs) { } private fun getFragment(position: Int): Fragment { + if (isUnivNavigated) { + return ConnectionTrackerFragment.newInstance(searchParam) + } + if (isWireGuardLogs) { + return when(position) { + 0 -> ConnectionTrackerFragment.newInstance(searchParam) + 1 -> WgNwStatsFragment.newInstance(searchParam) + else -> ConnectionTrackerFragment.newInstance(searchParam) + } + } return when (position) { 0 -> { if (appConfig.getBraveMode().isDnsMode()) { @@ -132,6 +184,14 @@ class NetworkLogsActivity : AppCompatActivity(R.layout.activity_network_logs) { // get tab text based on brave mode private fun getTabText(position: Int): String { + if (isWireGuardLogs) { + return when(position) { + 0 -> getString(R.string.firewall_act_network_monitor_tab) + 1 -> getString(R.string.title_statistics) + else -> getString(R.string.firewall_act_network_monitor_tab) + } + } + return when (position) { 0 -> { if (appConfig.getBraveMode().isDnsMode()) { diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/PauseActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/PauseActivity.kt index 27c3eb091..390bc1f51 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/activity/PauseActivity.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/activity/PauseActivity.kt @@ -23,6 +23,7 @@ import android.os.Bundle import android.os.SystemClock import android.view.MotionEvent import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.lifecycleScope import by.kirich1409.viewbindingdelegate.viewBinding import com.celzero.bravedns.R @@ -32,9 +33,9 @@ import com.celzero.bravedns.service.FirewallManager import com.celzero.bravedns.service.PauseTimer.PAUSE_VPN_EXTRA_MILLIS import com.celzero.bravedns.service.PersistentState import com.celzero.bravedns.service.VpnController -import com.celzero.bravedns.ui.HomeScreenActivity import com.celzero.bravedns.util.Constants.Companion.INIT_TIME_MS import com.celzero.bravedns.util.Themes +import com.celzero.bravedns.util.Utilities.isAtleastQ import kotlinx.coroutines.CompletableJob import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -60,6 +61,13 @@ class PauseActivity : AppCompatActivity(R.layout.activity_pause) { override fun onCreate(savedInstanceState: Bundle?) { setTheme(Themes.getCurrentTheme(isDarkThemeOn(), persistentState.theme)) super.onCreate(savedInstanceState) + + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + initView() initClickListeners() observeAppState() @@ -159,9 +167,8 @@ class PauseActivity : AppCompatActivity(R.layout.activity_pause) { } lastStopActivityInvokeTime = SystemClock.elapsedRealtime() - val intent = Intent(this, HomeScreenActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK - intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK + val intent = Intent(this, AppLockActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK startActivity(intent) finish() } diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/PingTestActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/PingTestActivity.kt new file mode 100644 index 000000000..c74e67fc8 --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/ui/activity/PingTestActivity.kt @@ -0,0 +1,264 @@ +/* + * Copyright 2024 RethinkDNS and its authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.celzero.bravedns.ui.activity + +import Logger +import android.content.Context +import android.content.res.Configuration +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.lifecycleScope +import com.celzero.firestack.backend.Backend +import by.kirich1409.viewbindingdelegate.viewBinding +import com.celzero.bravedns.R +import com.celzero.bravedns.databinding.ActivityPingTestBinding +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.service.VpnController +import com.celzero.bravedns.util.Themes +import com.celzero.bravedns.util.Utilities.isAtleastQ +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koin.android.ext.android.inject + +class PingTestActivity: AppCompatActivity(R.layout.activity_ping_test) { + private val b by viewBinding(ActivityPingTestBinding::bind) + + private val persistentState by inject() + + companion object { + private const val TAG = "PingUi" + private const val PING_IP1 = "1.1.1.1:53" + private const val PING_IP2 = "8.8.8.8:53" + private const val PING_IP3 = "216.239.32.27:443" + private const val PING_HOST1 = "cloudflare.com:443" + private const val PING_HOST2 = "google.com:443" + private const val PING_HOST3 = "brave.com:443" + } + + private val proxiesStatus = mutableListOf() + + + override fun onCreate(savedInstanceState: Bundle?) { + setTheme(Themes.getCurrentTheme(isDarkThemeOn(), persistentState.theme)) + super.onCreate(savedInstanceState) + + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + initView() + setupClickListeners() + } + + private fun Context.isDarkThemeOn(): Boolean { + return resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == + Configuration.UI_MODE_NIGHT_YES + } + + private fun initView() { + if (!VpnController.hasTunnel()) { + showStartVpnDialog() + return + } + + b.pingButton.text = b.pingButton.text.toString().uppercase() + b.cancelButton.text = b.cancelButton.text.toString().uppercase() + + b.ipAddress1.text = PING_IP1 + b.ipAddress2.text = PING_IP2 + b.ipAddress3.setText(PING_IP3) + + b.hostAddress1.text = PING_HOST1 + b.hostAddress2.text = PING_HOST2 + b.hostAddress3.setText(PING_HOST3) + } + + private fun showStartVpnDialog() { + val builder = MaterialAlertDialogBuilder(this) + builder.setTitle(getString(R.string.vpn_not_active_dialog_title)) + builder.setMessage(getString(R.string.vpn_not_active_dialog_desc)) + builder.setCancelable(false) + builder.setPositiveButton(getString(R.string.dns_info_positive)) { dialogInterface, _ -> + dialogInterface.dismiss() + finish() + } + builder.create().show() + } + + private fun setupClickListeners() { + b.pingButton.setOnClickListener { + performPing() + } + + b.cancelButton.setOnClickListener { + finish() + } + } + + private fun performPing() { + try { + Logger.v(Logger.LOG_IAB, "$TAG initiating ping test") + b.progressIp1.visibility = View.VISIBLE + b.progressIp2.visibility = View.VISIBLE + b.progressIp3.visibility = View.VISIBLE + b.progressHost1.visibility = View.VISIBLE + b.progressHost2.visibility = View.VISIBLE + b.progressHost3.visibility = View.VISIBLE + + val ip1 = b.ipAddress1.text.toString() + val ip2 = b.ipAddress2.text.toString() + val ip3 = b.ipAddress3.text.toString() + val host1 = b.hostAddress1.text.toString() + val host2 = b.hostAddress2.text.toString() + val host3 = b.hostAddress3.text.toString() + + io { + val validI1 = isReachable(ip1) + val validI2 = isReachable(ip2) + val validI3 = isReachable(ip3) + + val validH1 = isReachable(host1) + val validH2 = isReachable(host2) + val validH3 = isReachable(host3) + Logger.d(Logger.LOG_IAB, "$TAG ip1 reachable: $validI1, ip2 reachable: $validI2, ip3 reachable: $validI3") + Logger.d(Logger.LOG_IAB, "$TAG host1 reachable: $validH1, host2 reachable: $validH2, host3 reachable: $validH3") + uiCtx { + b.progressIp1.visibility = View.GONE + b.progressIp2.visibility = View.GONE + b.progressIp3.visibility = View.GONE + b.progressHost1.visibility = View.GONE + b.progressHost2.visibility = View.GONE + b.progressHost3.visibility = View.GONE + + b.statusIp1.visibility = View.VISIBLE + b.statusIp2.visibility = View.VISIBLE + b.statusIp3.visibility = View.VISIBLE + b.statusHost1.visibility = View.VISIBLE + b.statusHost2.visibility = View.VISIBLE + b.statusHost3.visibility = View.VISIBLE + + b.statusIp1.setImageDrawable(getImgRes(validI1)) + b.statusIp2.setImageDrawable(getImgRes(validI2)) + b.statusIp3.setImageDrawable(getImgRes(validI3)) + b.statusHost1.setImageDrawable(getImgRes(validH1)) + b.statusHost2.setImageDrawable(getImgRes(validH2)) + b.statusHost3.setImageDrawable(getImgRes(validH3)) + } + + val strength = calculateStrength(ip3) + Logger.d(Logger.LOG_IAB, "$TAG strength: $strength for $ip3") + uiCtx { + setStrengthLevel(strength) + } + } + } catch (e: Exception) { + Logger.e(Logger.LOG_IAB, "$TAG err isReachable: ${e.message}", e) + } + + } + + private fun getImgRes(probeResult: Boolean): Drawable? { + val failureDrawable = ContextCompat.getDrawable(this, R.drawable.ic_cross_accent) + val successDrawable = ContextCompat.getDrawable(this, R.drawable.ic_tick) + + return if (probeResult) { + successDrawable + } else { + failureDrawable + } + } + + private fun setStrengthLevel(strength: Int) { + val max = 5 + // Ensure the strength is between 1 and 5 + val validStrength = when { + strength < 1 -> 1 + strength > max -> max + else -> strength + } + + b.strengthLayout.visibility = View.VISIBLE + // Update the progress of the ProgressBar + b.strengthIndicator.max = max + b.strengthIndicator.progress = validStrength + b.pingResult.text = getString(R.string.two_argument, validStrength.toString(), max.toString()) + } + + private suspend fun isReachable(csv: String): Boolean { + val (warp, pr, se, w64, exit) = if (proxiesStatus.isEmpty()) { + getProxiesStatus(csv) + } else { + proxiesStatus + } + Logger.d(Logger.LOG_IAB, "$TAG ip $csv reachable: $warp, $pr, $se, $w64, $exit") + Logger.i(Logger.LOG_IAB, "$TAG ip $csv reachable: ${warp || pr || se || w64 || exit}") + return warp || se || w64 || exit + } + + private suspend fun calculateStrength(csv: String): Int { + val (wg, amz, proton, se, w64) = if (proxiesStatus.isEmpty()) { + getProxiesStatus(csv) + } else { + proxiesStatus + } + + // calculate strength based on the above boolean values + // 1 - 5 + var strength = 0 + if (wg) strength++ + if (amz) strength++ + if (proton) strength++ + if (se) strength++ + if (w64) strength++ + + Logger.i(Logger.LOG_IAB, "$TAG strength: $strength ($wg, $amz, $se, $w64 )") + return strength + } + + private suspend fun getProxiesStatus(csv: String): List { + if (proxiesStatus.isNotEmpty()) return proxiesStatus + + val warp = VpnController.isProxyReachable(Backend.RpnWg, csv) + val amz = VpnController.isProxyReachable(Backend.RpnAmz, csv) + val proton = VpnController.isProxyReachable(Backend.RpnPro, csv) + val se = VpnController.isProxyReachable(Backend.RpnSE, csv) + val w64 = VpnController.isProxyReachable(Backend.Rpn64, csv) + Logger.d(Logger.LOG_IAB, "$TAG proxies reachable: $warp, $amz $proton, $se, $w64") + return proxiesStatus.apply { + clear() + add(warp) + add(amz) + add(proton) + add(se) + add(w64) + } + } + + private suspend fun uiCtx(f: suspend () -> Unit) { + withContext(Dispatchers.Main) { f() } + } + + private fun io(f: suspend () -> Unit) { + lifecycleScope.launch(Dispatchers.IO) { f() } + } +} diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/ProxySettingsActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/ProxySettingsActivity.kt index 744a24491..8f7b791a5 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/activity/ProxySettingsActivity.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/activity/ProxySettingsActivity.kt @@ -17,7 +17,6 @@ package com.celzero.bravedns.ui.activity import Logger import Logger.LOG_TAG_PROXY -import Logger.LOG_TAG_UI import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent @@ -37,13 +36,16 @@ import android.widget.Spinner import android.widget.TextView import android.widget.Toast import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.lifecycleScope import by.kirich1409.viewbindingdelegate.viewBinding import com.celzero.bravedns.R import com.celzero.bravedns.data.AppConfig import com.celzero.bravedns.database.ProxyEndpoint +import com.celzero.bravedns.database.ProxyEndpoint.Companion.DEFAULT_PROXY_TYPE import com.celzero.bravedns.databinding.DialogSetProxyBinding import com.celzero.bravedns.databinding.FragmentProxyConfigureBinding +import com.celzero.bravedns.rpnproxy.RpnProxyManager import com.celzero.bravedns.service.FirewallManager import com.celzero.bravedns.service.PersistentState import com.celzero.bravedns.service.ProxyManager @@ -62,11 +64,11 @@ import com.celzero.bravedns.util.Utilities.isAtleastQ import com.celzero.bravedns.util.Utilities.isValidPort import com.celzero.bravedns.util.Utilities.showToastUiCentered import com.google.android.material.dialog.MaterialAlertDialogBuilder -import java.util.concurrent.TimeUnit import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.android.ext.android.inject +import java.util.concurrent.TimeUnit class ProxySettingsActivity : AppCompatActivity(R.layout.fragment_proxy_configure) { private val b by viewBinding(FragmentProxyConfigureBinding::bind) @@ -94,6 +96,13 @@ class ProxySettingsActivity : AppCompatActivity(R.layout.fragment_proxy_configur override fun onCreate(savedInstanceState: Bundle?) { setTheme(getCurrentTheme(isDarkThemeOn(), persistentState.theme)) super.onCreate(savedInstanceState) + + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + initAnimation() initView() initClickListeners() @@ -126,17 +135,25 @@ class ProxySettingsActivity : AppCompatActivity(R.layout.fragment_proxy_configur b.orbotTitle.text = getString(R.string.orbot).lowercase() b.otherTitle.text = getString(R.string.category_name_others).lowercase() - observeCustomProxy() - displayTcpProxyUi() + if (RpnProxyManager.isRpnActive()) { + b.rpnTitle.visibility = View.VISIBLE + b.settingsActivityRpnContainer.visibility = View.VISIBLE + } else { + b.rpnTitle.visibility = View.GONE + b.settingsActivityRpnContainer.visibility = View.GONE + } displayHttpProxyUi() displaySocks5Ui() } private fun initClickListeners() { - b.wgRefresh.setOnClickListener { refresh() } + b.settingsActivityRpnContainer.setOnClickListener { + val intent = Intent(this, RethinkPlusDashboardActivity::class.java) + startActivity(intent) + } - b.settingsActivityTcpProxyContainer.setOnClickListener { handleTcpProxy() } + b.wgRefresh.setOnClickListener { refresh() } b.settingsActivitySocks5Rl.setOnClickListener { b.settingsActivitySocks5Switch.isChecked = !b.settingsActivitySocks5Switch.isChecked @@ -256,68 +273,6 @@ class ProxySettingsActivity : AppCompatActivity(R.layout.fragment_proxy_configur } } - private fun handleTcpProxy() { - // disable the click event until below coroutine is completed. - disableTcpProxyUi() - - io { - when (TcpProxyHelper.getTcpProxyPaymentStatus()) { - TcpProxyHelper.PaymentStatus.PAID -> { - uiCtx { - enableTcpProxyUi() - val intent = Intent(this, TcpProxyMainActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED - startActivity(intent) - } - } - TcpProxyHelper.PaymentStatus.INITIATED -> { - uiCtx { - enableTcpProxyUi() - val intent = Intent(this, CheckoutActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED - startActivity(intent) - } - } - else -> { - val isTcpWorking = TcpProxyHelper.publicKeyUsable() - uiCtx { - if (isTcpWorking) { - enableTcpProxyUi() - val intent = Intent(this, CheckoutActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED - startActivity(intent) - } else { - showTcpProxyErrorDialog() - enableTcpProxyUi() - } - } - } - } - } - } - - private fun disableTcpProxyUi() { - b.settingsActivityTcpProxyContainer.isClickable = false - b.settingsActivityTcpProxyProgress.visibility = View.VISIBLE - b.settingsActivityTcpProxyImg.visibility = View.GONE - } - - private fun enableTcpProxyUi() { - b.settingsActivityTcpProxyContainer.isClickable = true - b.settingsActivityTcpProxyProgress.visibility = View.GONE - b.settingsActivityTcpProxyImg.visibility = View.VISIBLE - } - - private fun showTcpProxyErrorDialog() { - val builder = MaterialAlertDialogBuilder(this) - builder.setTitle("Rethink Proxy") - builder.setMessage( - "Issue checking for Rethink Proxy. There may be a problem with your network or the proxy server. Please try again later." - ) - builder.setPositiveButton("Okay") { dialog, _ -> dialog.dismiss() } - builder.create().show() - } - /** Prompt user to download the Orbot app based on the current BUILDCONFIG flavor. */ private fun showOrbotInstallDialog() { val builder = MaterialAlertDialogBuilder(this) @@ -415,10 +370,6 @@ class ProxySettingsActivity : AppCompatActivity(R.layout.fragment_proxy_configur } private fun displayHttpProxyUi() { - if (!isAtleastQ()) { - b.settingsActivityHttpProxyContainer.visibility = View.GONE - return - } val isCustomHttpProxyEnabled = appConfig.isCustomHttpProxyEnabled() b.settingsActivityHttpProxyContainer.visibility = View.VISIBLE b.settingsActivityHttpProxySwitch.isChecked = isCustomHttpProxyEnabled @@ -442,28 +393,9 @@ class ProxySettingsActivity : AppCompatActivity(R.layout.fragment_proxy_configur } } - private fun displayTcpProxyUi() { - // v055f, no-op - return - - val tcpProxies = TcpProxyHelper.getActiveTcpProxy() - if (tcpProxies == null || !tcpProxies.isActive) { - b.settingsActivityTcpProxyDesc.text = - "Not active" // getString(R.string.tcp_proxy_description) - return - } - - Logger.i( - LOG_TAG_UI, - "displayTcpProxyUi: ${tcpProxies?.isActive}, ${tcpProxies?.name}, ${tcpProxies?.url}" - ) - b.settingsActivityTcpProxyDesc.text = - "Active" // getString(R.string.tcp_proxy_description_active) - } - private fun displayWireguardUi() { - val activeWgs = WireguardManager.getEnabledConfigs() + val activeWgs = WireguardManager.getActiveConfigs() if (activeWgs.isEmpty()) { b.settingsActivityWireguardDesc.text = getString(R.string.wireguard_description) @@ -473,10 +405,10 @@ class ProxySettingsActivity : AppCompatActivity(R.layout.fragment_proxy_configur var wgStatus = "" activeWgs.forEach { val id = ProxyManager.ID_WG_BASE + it.getId() - val statusId = VpnController.getProxyStatusById(id) + val statusPair = VpnController.getProxyStatusById(id) uiCtx { - if (statusId != null) { - val resId = UIUtils.getProxyStatusStringRes(statusId) + if (statusPair.first != null) { + val resId = UIUtils.getProxyStatusStringRes(statusPair.first) val s = getString(resId).replaceFirstChar(Char::titlecase) wgStatus += getString( R.string.ci_ip_label, @@ -560,22 +492,6 @@ class ProxySettingsActivity : AppCompatActivity(R.layout.fragment_proxy_configur } } - private fun observeCustomProxy() { - /*appConfig.connectedProxy.observe(this) { - proxyEndpoint = it - if (proxyEndpoint == null) return@observe - - val m = ProxyManager.ProxyMode.get(proxyEndpoint!!.proxyMode) ?: return@observe - if (m.isCustomSocks5()) { - displaySocks5Ui() - } else if (m.isCustomHttp()) { - displayHttpProxyUi() - } else { - // no-op - } - }*/ - } - private fun refreshOrbotUi() { // Checks whether the Orbot is installed. // If not, then prompt the user for installation. @@ -745,7 +661,7 @@ class ProxySettingsActivity : AppCompatActivity(R.layout.fragment_proxy_configur if (Utilities.isLanIpv4(ip)) { Utilities.isValidLocalPort(port) } else { - Utilities.isValidPort(port) + isValidPort(port) } if (!isValid) { errorTxt.text = getString(R.string.settings_http_proxy_error_text1) @@ -807,7 +723,6 @@ class ProxySettingsActivity : AppCompatActivity(R.layout.fragment_proxy_configur b.settingsActivityOrbotContainer.alpha = 1f b.settingsActivityVpnLockdownDesc.visibility = View.GONE b.settingsActivityWireguardContainer.alpha = 1f - b.settingsActivityTcpProxyContainer.alpha = 1f b.settingsActivitySocks5Rl.alpha = 1f b.settingsActivityHttpProxyContainer.alpha = 1f b.wgRefresh.visibility = View.VISIBLE @@ -815,7 +730,6 @@ class ProxySettingsActivity : AppCompatActivity(R.layout.fragment_proxy_configur b.settingsActivityOrbotContainer.alpha = 0.5f b.settingsActivityWireguardContainer.alpha = 0.5f b.settingsActivityVpnLockdownDesc.visibility = View.VISIBLE - b.settingsActivityTcpProxyContainer.alpha = 0.5f b.settingsActivitySocks5Rl.alpha = 0.5f b.settingsActivityHttpProxyContainer.alpha = 0.5f b.wgRefresh.visibility = View.GONE @@ -824,9 +738,6 @@ class ProxySettingsActivity : AppCompatActivity(R.layout.fragment_proxy_configur // Wireguard b.settingsActivityWireguardImg.isEnabled = canEnableProxy b.settingsActivityWireguardContainer.isEnabled = canEnableProxy - // TCP Proxy - b.settingsActivityTcpProxyIcon.isEnabled = canEnableProxy - b.settingsActivityTcpProxyContainer.isEnabled = canEnableProxy // Orbot b.settingsActivityOrbotImg.isEnabled = canEnableProxy b.settingsActivityOrbotContainer.isEnabled = canEnableProxy @@ -1062,7 +973,7 @@ class ProxySettingsActivity : AppCompatActivity(R.layout.fragment_proxy_configur id, name, mode.value, - proxyType = "NONE", + proxyType = DEFAULT_PROXY_TYPE, appName, ip, port, diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/RethinkPlusDashboardActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/RethinkPlusDashboardActivity.kt new file mode 100644 index 000000000..a7b381e05 --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/ui/activity/RethinkPlusDashboardActivity.kt @@ -0,0 +1,720 @@ +/* + * Copyright 2025 RethinkDNS and its authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.celzero.bravedns.ui.activity + +import Logger +import Logger.LOG_TAG_UI +import android.content.ClipData +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.net.Uri +import android.os.Bundle +import android.text.method.LinkMovementMethod +import android.view.View +import android.view.WindowManager +import android.view.animation.Animation +import android.view.animation.RotateAnimation +import android.widget.CompoundButton +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.AppCompatImageView +import androidx.appcompat.widget.AppCompatTextView +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.celzero.firestack.backend.Backend +import by.kirich1409.viewbindingdelegate.viewBinding +import com.celzero.bravedns.R +import com.celzero.bravedns.databinding.ActivityRethinkPlusDashboardBinding +import com.celzero.bravedns.databinding.DialogInfoRulesLayoutBinding +import com.celzero.bravedns.rpnproxy.RpnProxyManager +import com.celzero.bravedns.scheduler.BugReportZipper.FILE_PROVIDER_NAME +import com.celzero.bravedns.scheduler.BugReportZipper.getZipFileName +import com.celzero.bravedns.scheduler.EnhancedBugReport +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.service.VpnController +import com.celzero.bravedns.util.Constants +import com.celzero.bravedns.util.Themes +import com.celzero.bravedns.util.UIUtils +import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.isAtleastQ +import com.celzero.bravedns.util.Utilities.showToastUiCentered +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koin.android.ext.android.inject +import java.io.File + +class RethinkPlusDashboardActivity : AppCompatActivity(R.layout.activity_rethink_plus_dashboard) { + private val b by viewBinding(ActivityRethinkPlusDashboardBinding::bind) + + private val persistentState by inject() + + private lateinit var animation: Animation + + private lateinit var options: List + + companion object { + private const val ANIMATION_DURATION = 750L + private const val ANIMATION_REPEAT_COUNT = -1 + private const val ANIMATION_PIVOT_VALUE = 0.5f + private const val ANIMATION_START_DEGREE = 0.0f + private const val ANIMATION_END_DEGREE = 360.0f + + private const val TAG = "RPNDashboardActivity" + + private const val DELAY = 1500L + } + + private var warpProps: RpnProxyManager.RpnProps? = null + private var seProps: RpnProxyManager.RpnProps? = null + private var exit64Props: RpnProxyManager.RpnProps? = null + private var protonProps: RpnProxyManager.RpnProps? = null + private var amzProps: RpnProxyManager.RpnProps? = null + + override fun onCreate(savedInstanceState: Bundle?) { + setTheme(Themes.getCurrentTheme(isDarkThemeOn(), persistentState.theme)) + theme.applyStyle(R.style.OptOutEdgeToEdgeEnforcement, false) + super.onCreate(savedInstanceState) + + // drop the last element as the last one is exit which is not used in the UI + options = resources.getStringArray(R.array.rpn_proxies_list).dropLast(1) + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + initView() + setupClickListeners() + } + + private fun Context.isDarkThemeOn(): Boolean { + return resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == + Configuration.UI_MODE_NIGHT_YES + } + + private fun initView() { + addAnimation() + handleRpnMode() + addProxiesToUi() + } + + private fun handleRpnMode() { + val mode = RpnProxyManager.rpnMode() + when (mode) { + RpnProxyManager.RpnMode.ANTI_CENSORSHIP -> { + b.rsAntiCensorshipRadio.isChecked = true + b.rsHideIpRadio.isChecked = false + b.rsOffRadio.isChecked = false + } + + RpnProxyManager.RpnMode.HIDE_IP -> { + b.rsAntiCensorshipRadio.isChecked = false + b.rsHideIpRadio.isChecked = true + b.rsOffRadio.isChecked = false + } + + RpnProxyManager.RpnMode.NONE -> { + b.rsAntiCensorshipRadio.isChecked = false + b.rsHideIpRadio.isChecked = false + b.rsOffRadio.isChecked = true + } + } + } + + private fun addProxiesToUi() { + val mode = RpnProxyManager.rpnMode() + ui { + for (option in options) { + val rowView = + layoutInflater.inflate( + R.layout.item_rpn_proxy_dashboard_stats, + b.proxyContainer, + false + ) + val iv = rowView.findViewById(R.id.icon) + val title = rowView.findViewById(R.id.title) + // treat refreshTv as a button + val refreshTv = rowView.findViewById(R.id.last_refresh_time_tv) + val refreshIv = rowView.findViewById(R.id.refresh_icon) + val lastRefreshLabel = rowView.findViewById(R.id.last_refresh_time_label) + val infoIv = rowView.findViewById(R.id.info) + val statusTv = rowView.findViewById(R.id.status) + val whoTv = rowView.findViewById(R.id.who) + + b.proxyContainer.addView(rowView) + val id = options.indexOf(option) + updateProxiesUi( + id, + iv, + title, + infoIv, + statusTv, + lastRefreshLabel, + whoTv + ) + + val type = getType(id) + if (type == null) { + Logger.w(LOG_TAG_UI, "$TAG type is null") + return@ui + } + + refreshTv.setOnClickListener { + if (!canRefresh(type)) { + Logger.w(LOG_TAG_UI, "$TAG refresh clicked but not allowed") + showToastUiCentered(this, "Refresh can be done only once in 24 hours", Toast.LENGTH_SHORT) + return@setOnClickListener + } + handleRefreshUi(type, refreshIv, refreshTv) + ui { + updateProxiesUi(id, iv, title, infoIv, statusTv, lastRefreshLabel, whoTv) + } + } + } + // do not update the UI if RPN is off + if (mode.isNone()) { + return@ui + } + + repeatOnLifecycle(Lifecycle.State.RESUMED) { + // your repeating logic + keepUpdatingProxiesUi() + } + } + } + + private fun canRefresh(type: RpnProxyManager.RpnType): Boolean { + val proxyDetail = RpnProxyManager.getProxy(type) + if (proxyDetail == null) { + Logger.w(LOG_TAG_UI, "$TAG proxy detail is null") + return true + } + val lastRefreshTime = proxyDetail.lastRefreshTime + val currentTime = System.currentTimeMillis() + val diff = currentTime - lastRefreshTime + val diffInHours = diff / (1000 * 60 * 60) + if (diffInHours < 24) { + Logger.w(LOG_TAG_UI, "$TAG refresh can be done only once in 24 hours") + return false + } + return true + } + + private fun reinitiateProxiesUi() { + ui { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + // your repeating logic + keepUpdatingProxiesUi() + } + } + } + + private suspend fun keepUpdatingProxiesUi() { + // keep updating the UI every 1.5 seconds + while (true) { + for (i in 0 until options.size) { + val iv = b.proxyContainer.getChildAt(i).findViewById(R.id.icon) + val title = b.proxyContainer.getChildAt(i).findViewById(R.id.title) + val infoIv = b.proxyContainer.getChildAt(i).findViewById(R.id.info) + val statusTv = b.proxyContainer.getChildAt(i).findViewById(R.id.status) + val whoTv = b.proxyContainer.getChildAt(i).findViewById(R.id.who) + val lastRefreshLabel = b.proxyContainer.getChildAt(i).findViewById(R.id.last_refresh_time_label) + + updateProxiesUi(i, iv, title, infoIv, statusTv, lastRefreshLabel, whoTv) + } + Logger.v(LOG_TAG_UI, "$TAG updating proxies UI every $DELAY ms") + delay(DELAY) + if (isFinishing || isDestroyed) { + Logger.v(LOG_TAG_UI, "$TAG activity is finishing, stopping update") + break + } + if (!RpnProxyManager.rpnState().isActive()) { + Logger.v(LOG_TAG_UI, "$TAG RPN is not active, stopping update") + break + } + } + } + + private suspend fun updateProxiesUi( + id: Int, + iv: AppCompatImageView, + title: AppCompatTextView, + infoIv: AppCompatImageView, + statusTv: AppCompatTextView, + lastRefreshLabel: AppCompatTextView, + whoTv: AppCompatTextView + ) { + val type = getType(id) ?: return // should never happen + + title.text = options[id] + iv.setImageDrawable(ContextCompat.getDrawable(this, getDrawable(type))) + + if (RpnProxyManager.rpnMode().isNone()) { + statusTv.text = getString(R.string.lbl_disabled) + statusTv.setTextColor(this, false) + return + } + + var res: Pair? = null + ioCtx { + res = VpnController.getRpnProps(type) + } + + val props = res?.first + // in case of both props and error message are null, show vpn not connected + val errMsg = res?.second ?: getString(R.string.notif_channel_vpn_failure) + + Logger.vv(LOG_TAG_UI, "$TAG updateProxiesUi $type props: $props") + if (props == null) { + infoIv.visibility = View.GONE + statusTv.text = errMsg + whoTv.text = "--" + lastRefreshLabel.text = "NA" + return + } + + setProps(type, props) + setStatus(props.status, statusTv) + updateLastRefreshTime(type, lastRefreshLabel) + if (props.who.isEmpty()) { + whoTv.text = "--" + } else { + whoTv.text = props.who + } + + infoIv.setOnClickListener { + showInfoDialog(type, props) + } + } + + private fun updateLastRefreshTime(type: RpnProxyManager.RpnType, lastRefreshTv: AppCompatTextView) { + when (type) { + RpnProxyManager.RpnType.WARP -> { + val warp = RpnProxyManager.getProxy(type) + lastRefreshTv.text = if (warp == null) "NA" else getTime(warp.lastRefreshTime) + } + RpnProxyManager.RpnType.AMZ -> { + val amz = RpnProxyManager.getProxy(type) + lastRefreshTv.text = if (amz == null) "NA" else getTime(amz.lastRefreshTime) + } + RpnProxyManager.RpnType.PROTON -> { + val proton = RpnProxyManager.getProxy(type) + lastRefreshTv.text = if (proton == null) "NA" else getTime(proton.lastRefreshTime) + } + RpnProxyManager.RpnType.SE -> { + lastRefreshTv.text = "NA" + } + RpnProxyManager.RpnType.EXIT_64 -> { + lastRefreshTv.text = "NA" + } + RpnProxyManager.RpnType.EXIT -> {} // not used in the UI + } + } + + private fun getTime(time: Long): String { + return Utilities.convertLongToTime(time, Constants.TIME_FORMAT_4) + } + + private fun setProps(type: RpnProxyManager.RpnType, props: RpnProxyManager.RpnProps) { + when (type) { + RpnProxyManager.RpnType.WARP -> warpProps = props + RpnProxyManager.RpnType.AMZ -> amzProps = props + RpnProxyManager.RpnType.PROTON -> protonProps = props + RpnProxyManager.RpnType.SE -> seProps = props + RpnProxyManager.RpnType.EXIT_64 -> exit64Props = props + RpnProxyManager.RpnType.EXIT -> {} // not used in the UI + } + } + + private fun getType(id: Int): RpnProxyManager.RpnType? { + return when (id) { + 0 -> RpnProxyManager.RpnType.WARP + 1 -> RpnProxyManager.RpnType.AMZ + 2 -> RpnProxyManager.RpnType.PROTON + 3 -> RpnProxyManager.RpnType.SE + 4 -> RpnProxyManager.RpnType.EXIT_64 + 5 -> RpnProxyManager.RpnType.EXIT // not used in the UI + else -> null + } + } + + private fun getDrawable(type: RpnProxyManager.RpnType?): Int { + return when (type) { + RpnProxyManager.RpnType.WARP -> R.drawable.ic_wireguard_icon + RpnProxyManager.RpnType.AMZ -> R.drawable.ic_wireguard_icon + RpnProxyManager.RpnType.PROTON -> R.drawable.ic_wireguard_icon + RpnProxyManager.RpnType.SE -> R.drawable.ic_wireguard_icon + RpnProxyManager.RpnType.EXIT_64 -> R.drawable.ic_wireguard_icon + RpnProxyManager.RpnType.EXIT -> R.drawable.ic_wireguard_icon // not used in the UI + null -> R.drawable.ic_wireguard_icon + } + } + + private fun handleRefreshUi(type: RpnProxyManager.RpnType, refreshIv: AppCompatImageView, refreshTv: AppCompatTextView) { + Logger.v(LOG_TAG_UI, "$TAG ${type.name} refresh clicked") + io { + uiCtx { + refreshTv.visibility = View.GONE + refreshIv.visibility = View.VISIBLE + refreshIv.isEnabled = false + refreshIv.animation = animation + refreshIv.startAnimation(animation) + } + handleRefresh(type) + uiCtx { + refreshIv.clearAnimation() + refreshIv.isEnabled = true + refreshIv.visibility = View.GONE + refreshTv.visibility = View.VISIBLE + } + } + } + + private suspend fun handleRefresh(type: RpnProxyManager.RpnType) { + when (type) { + RpnProxyManager.RpnType.WARP -> recreateAndAddWarp() + RpnProxyManager.RpnType.AMZ -> recreateAndAddAmz() + RpnProxyManager.RpnType.PROTON -> recreateAndAddProton() + RpnProxyManager.RpnType.SE -> reRegisterSE() + else -> {} // no refresh needed for exit64 and exit + } + } + + private fun setupClickListeners() { + + b.rsOffRl.setOnClickListener { + val checked = b.rsOffRadio.isChecked + if (!checked) { + b.rsOffRadio.isChecked = true + } + handleRPlusOff(checked) + } + + b.rsOffRadio.setOnCheckedChangeListener { _: CompoundButton, checked: Boolean -> + handleRPlusOff(checked) + } + + b.rsAntiCensorshipRl.setOnClickListener { + val checked = b.rsAntiCensorshipRadio.isChecked + if (!checked) { + b.rsAntiCensorshipRadio.isChecked = true + } + handleAntiCensorshipMode(checked) + } + + b.rsAntiCensorshipRadio.setOnCheckedChangeListener { _: CompoundButton, checked: Boolean -> + handleAntiCensorshipMode(checked) + } + + b.rsHideIpRl.setOnClickListener { + val checked = b.rsHideIpRadio.isChecked + if (!checked) { + b.rsHideIpRadio.isChecked = true + } + handleHideIpMode(checked) + } + + b.rsHideIpRadio.setOnCheckedChangeListener { _: CompoundButton, checked: Boolean -> + handleHideIpMode(checked) + } + + b.reportIssueRl.setOnClickListener { + collectDataForTroubleshoot() + } + + b.pingTestRl.setOnClickListener { + val intent = Intent(this, PingTestActivity::class.java) + startActivity(intent) + } + } + + private fun handleAntiCensorshipMode(checked: Boolean) { + Logger.v(LOG_TAG_UI, "$TAG Anti-censorship mode selected? $checked") + if (!checked) return + + b.rsHideIpRadio.isChecked = false + b.rsOffRadio.isChecked = false + persistentState.rpnMode = RpnProxyManager.RpnMode.ANTI_CENSORSHIP.id + reinitiateProxiesUi() + Logger.i(LOG_TAG_UI, "$TAG Anti-censorship selected, mode: ${persistentState.rpnMode}, state: ${persistentState.rpnState}") + } + + private fun handleHideIpMode(checked: Boolean) { + Logger.v(LOG_TAG_UI, "$TAG Hide IP mode selected? $checked") + if (!checked) return + + b.rsAntiCensorshipRadio.isChecked = false + b.rsOffRadio.isChecked = false + persistentState.rpnMode = RpnProxyManager.RpnMode.HIDE_IP.id + reinitiateProxiesUi() + Logger.i(LOG_TAG_UI, "$TAG Hide IP selected, mode: ${persistentState.rpnMode}, state: ${persistentState.rpnState}") + } + + private fun handleRPlusOff(checked: Boolean) { + Logger.v(LOG_TAG_UI, "$TAG Off mode selected? $checked") + if (!checked) return + + b.rsHideIpRadio.isChecked = false + b.rsAntiCensorshipRadio.isChecked = false + persistentState.rpnMode = RpnProxyManager.RpnMode.NONE.id + reinitiateProxiesUi() + Logger.i(LOG_TAG_UI, "$TAG off mode selected, mode: ${persistentState.rpnMode}, state: ${persistentState.rpnState}") + } + + private fun showInfoDialog(type: RpnProxyManager.RpnType,prop: RpnProxyManager.RpnProps) { + val title = type.name.uppercase() + val msg = prop.toString() + showTroubleshootDialog(title, msg, isInfo = true) + } + + private fun setStatus(status: Long?, txtView: AppCompatTextView) { + if (status == null) { + txtView.text = getString(R.string.lbl_disabled) + txtView.setTextColor(this, false) + } else { + val statusTxt = UIUtils.getProxyStatusStringRes(status) + txtView.text = getString(statusTxt).replaceFirstChar(Char::uppercase) + val isPositive = status == Backend.TUP || status == Backend.TOK + txtView.setTextColor(this, isPositive) + } + } + + private suspend fun recreateAndAddWarp() { + createWarpConfig() + } + + private suspend fun recreateAndAddAmz() { + createAmzConfig() + } + + private suspend fun recreateAndAddProton() { + // create a new proton config + val config = RpnProxyManager.getNewProtonConfig() + if (config == null) { + Logger.e(LOG_TAG_UI, "$TAG err creating proton config") + showConfigCreationError(getString(R.string.err_proton_creation_toast)) + return + } + Logger.i(LOG_TAG_UI, "$TAG proton config created") + } + + private suspend fun reRegisterSE() { + // add the SE to the tunnel + val isRegistered = VpnController.registerSEToTunnel() + if (!isRegistered) { + Logger.e(LOG_TAG_UI, "$TAG err registering SE to tunnel") + showConfigCreationError(getString(R.string.err_se_creation_toast)) + } + Logger.i(LOG_TAG_UI, "$TAG SE registered to tunnel") + } + + private suspend fun createWarpConfig(): Boolean { + // create a new warp config + val config = RpnProxyManager.getNewWarpConfig() + if (config == null) { + Logger.e(LOG_TAG_UI, "$TAG err creating warp config") + showConfigCreationError(getString(R.string.new_warp_error_toast)) + return false + } + return true + } + + private suspend fun createAmzConfig(): Boolean { + // create a new amz config + val config = RpnProxyManager.getNewAmzConfig() + if (config == null) { + Logger.e(LOG_TAG_UI, "$TAG err creating amz config") + showConfigCreationError(getString(R.string.err_amz_creation_toast)) + return false + } + return true + } + + private fun addAnimation() { + animation = + RotateAnimation( + ANIMATION_START_DEGREE, + ANIMATION_END_DEGREE, + Animation.RELATIVE_TO_SELF, + ANIMATION_PIVOT_VALUE, + Animation.RELATIVE_TO_SELF, + ANIMATION_PIVOT_VALUE + ) + animation.repeatCount = ANIMATION_REPEAT_COUNT + animation.duration = ANIMATION_DURATION + } + + private fun showTroubleshootDialog(title: String, msg: String, isInfo: Boolean = false) { + io { + uiCtx { + val dialogBinding = DialogInfoRulesLayoutBinding.inflate(layoutInflater) + val builder = + MaterialAlertDialogBuilder(this).setView(dialogBinding.root) + val lp = WindowManager.LayoutParams() + val dialog = builder.create() + dialog.show() + lp.copyFrom(dialog.window?.attributes) + lp.width = WindowManager.LayoutParams.MATCH_PARENT + lp.height = WindowManager.LayoutParams.WRAP_CONTENT + + dialog.setCancelable(true) + dialog.window?.attributes = lp + + val heading = dialogBinding.infoRulesDialogRulesTitle + val cancelBtn = dialogBinding.infoRulesDialogCancelImg + val okBtn = dialogBinding.infoRulesDialogOkBtn + val descText = dialogBinding.infoRulesDialogRulesDesc + dialogBinding.infoRulesDialogRulesIcon.visibility = View.GONE + + heading.text = title + if (!isInfo) { + okBtn.visibility = View.VISIBLE + okBtn.text = getString(R.string.about_bug_report_dialog_positive_btn) + } + heading.setCompoundDrawablesWithIntrinsicBounds( + ContextCompat.getDrawable(this, R.drawable.ic_rethink_plus), + null, + null, + null + ) + + descText.movementMethod = LinkMovementMethod.getInstance() + descText.text = msg + + cancelBtn.setOnClickListener { dialog.dismiss() } + + okBtn.setOnClickListener { emailBugReport(msg) } + + dialog.show() + } + } + } + + private fun getFileUri(file: File): Uri? { + if (file.isFile && file.exists()) { + return FileProvider.getUriForFile( + applicationContext, + FILE_PROVIDER_NAME, + file + ) + } + return null + } + + private fun emailBugReport(msg: String) { + try { + // get the rethink.tombstone file + val tombstoneFile: File? = EnhancedBugReport.getTombstoneZipFile(this) + + // get the bug_report.zip file + val file = File(getZipFileName(filesDir)) + val uri = getFileUri(file) ?: throw Exception("file uri is null") + + // create an intent for sending email with multiple attachments + val emailIntent = Intent(Intent.ACTION_SEND_MULTIPLE) + emailIntent.type = "text/plain" + emailIntent.putExtra(Intent.EXTRA_EMAIL, arrayOf(getString(R.string.about_mail_to))) + emailIntent.putExtra( + Intent.EXTRA_SUBJECT, + getString(R.string.about_mail_plus_bugreport_subject) + ) + val bugReportText = getString(R.string.about_mail_bugreport_text) + "\n\n" + msg + + val uriList = arrayListOf() + uriList.add(uri) + + // add the tombstone file if it exists + if (tombstoneFile != null) { + val tombstoneUri = + getFileUri(tombstoneFile) ?: throw Exception("tombstoneUri is null") + uriList.add(tombstoneUri) + } + // add the uri list to the email intent + emailIntent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uriList) + emailIntent.putExtra(Intent.EXTRA_TEXT, bugReportText) + + Logger.i(LOG_TAG_UI, "email with attachment: $uri, ${tombstoneFile?.path}") + emailIntent.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + if (uriList.isNotEmpty()) { + val clipData = ClipData.newUri(contentResolver, "Logs", uriList[0]) + for (i in 1 until uriList.size) { + clipData.addItem(ClipData.Item(uriList[i])) + } + emailIntent.clipData = clipData + } + Logger.i(LOG_TAG_UI, "email with attachment: $uri, ${tombstoneFile?.path}") + startActivity( + Intent.createChooser( + emailIntent, + getString(R.string.about_mail_bugreport_share_title) + ) + ) + } catch (e: Exception) { + showToastUiCentered( + this, + getString(R.string.error_loading_log_file), + Toast.LENGTH_SHORT + ) + Logger.e(LOG_TAG_UI, "error sending email: ${e.message}", e) + } + } + + private fun collectDataForTroubleshoot() { + io { + val title = "Proxy Stats" + val rpnStats = + "WARP \n" + warpProps?.toString() + "\n\n" + "Amz \n" + amzProps?.toString() + "\n\n" + "Proton \n" + protonProps?.toString() + "\n\n" + "\n\n" + "SE \n" + seProps?.toString() + "Exit64 \n" + exit64Props?.toString() + "\n\n" + val stats = rpnStats + VpnController.vpnStats() + uiCtx { + showTroubleshootDialog(title, stats) + } + } + } + + private fun AppCompatTextView.setTextColor(context: Context, success: Boolean) { + this.setTextColor( + if (success) UIUtils.fetchColor(context, R.attr.chipTextPositive) + else UIUtils.fetchColor(context, R.attr.chipTextNegative) + ) + } + + private suspend fun showConfigCreationError(msg: String) { + uiCtx { showToastUiCentered(this, msg, Toast.LENGTH_LONG) } + } + + private suspend fun uiCtx(f: suspend () -> Unit) { + withContext(Dispatchers.Main) { f() } + } + + private fun io(f: suspend () -> Unit) { + lifecycleScope.launch(Dispatchers.IO) { f() } + } + + private suspend fun ioCtx(f: suspend () -> Unit) { + withContext(Dispatchers.IO) { f() } + } + + private fun ui(f: suspend () -> Unit) { + lifecycleScope.launch(Dispatchers.Main) { f() } + } +} diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/RpnAvailabilityCheckActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/RpnAvailabilityCheckActivity.kt new file mode 100644 index 000000000..ede034162 --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/ui/activity/RpnAvailabilityCheckActivity.kt @@ -0,0 +1,148 @@ +/* + * Copyright 2025 RethinkDNS and its authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.celzero.bravedns.ui.activity + +import Logger +import android.content.Context +import android.content.res.Configuration +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import android.os.Bundle +import android.view.View +import android.widget.ProgressBar +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.lifecycleScope +import by.kirich1409.viewbindingdelegate.viewBinding +import com.celzero.bravedns.R +import com.celzero.bravedns.databinding.ActivityRpnAvailabililtyBinding +import com.celzero.bravedns.rpnproxy.RpnProxyManager +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.service.VpnController +import com.celzero.bravedns.util.Themes.Companion.getCurrentTheme +import com.celzero.bravedns.util.UIUtils +import com.celzero.bravedns.util.Utilities.isAtleastQ +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koin.android.ext.android.inject + +class RpnAvailabilityCheckActivity : AppCompatActivity() { + private val b by viewBinding(ActivityRpnAvailabililtyBinding::bind) + private val persistentState by inject() + + private lateinit var options: Array + + // TODO - #324 - Usage of isDarkTheme() in all activities. + private fun Context.isDarkThemeOn(): Boolean { + return resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == + UI_MODE_NIGHT_YES + } + + companion object { + private const val TAG = "RpnAvailabilityCheckActivity" + } + + override fun onCreate(savedInstanceState: Bundle?) { + setTheme(getCurrentTheme(isDarkThemeOn(), persistentState.theme)) + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_rpn_availabililty) + + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + options = resources.getStringArray(R.array.rpn_proxies_list) + startChecks() + } + + private fun startChecks() { + var strength = 0 + val ctx = this + lifecycleScope.launch { + ui { + for (option in options) { + val rowView = + layoutInflater.inflate( + R.layout.item_rpn_availability_row, + b.statusContainer, + false + ) + val optionText = rowView.findViewById(R.id.optionName) + val resultText = rowView.findViewById(R.id.resultText) + val loader = rowView.findViewById(R.id.loader) + + optionText.text = option + resultText.visibility = View.GONE + loader.visibility = View.VISIBLE + + b.statusContainer.addView(rowView) + var res = false + ioCtx { + res = getProxiesStatus(options.indexOf(option)) + delay(1000) + } + + loader.visibility = View.GONE + + if (res) { + strength++ + resultText.text = getString(R.string.lbl_active) + resultText.setTextColor(UIUtils.fetchColor(ctx, R.attr.accentGood)) + resultText.visibility = View.VISIBLE + } else { + resultText.text = getString(R.string.lbl_inactive) + resultText.setTextColor(UIUtils.fetchColor(ctx, R.attr.accentBad)) + resultText.visibility = View.VISIBLE + } + + Logger.i(Logger.LOG_IAB, "$TAG strength: $strength ($res)") + + b.strengthProgress.max = 6 + b.strengthProgress.setProgressCompat(strength, true) + b.strengthText.text = "$strength/${b.strengthProgress.max}" + } + } + } + } + + private suspend fun getProxiesStatus(value: Int): Boolean { + val res = when (value) { + 0 -> VpnController.testRpnProxy(RpnProxyManager.RpnType.WARP) + 1 -> VpnController.testRpnProxy(RpnProxyManager.RpnType.AMZ) + 2 -> VpnController.testRpnProxy(RpnProxyManager.RpnType.PROTON) + 3 -> VpnController.testRpnProxy(RpnProxyManager.RpnType.SE) + 4 -> VpnController.testRpnProxy(RpnProxyManager.RpnType.EXIT_64) + 5 -> VpnController.testRpnProxy(RpnProxyManager.RpnType.EXIT) + else -> { + Logger.e(Logger.LOG_IAB, "$TAG invalid proxy type") + return false + } + } + + return res + } + + private suspend fun ioCtx(f: suspend () -> Unit) { + withContext(Dispatchers.IO) { f() } + } + + private fun ui(f: suspend () -> Unit) { + lifecycleScope.launch(Dispatchers.Main) { f() } + } +} diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/RpnCountriesActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/RpnCountriesActivity.kt new file mode 100644 index 000000000..00e2d8f8a --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/ui/activity/RpnCountriesActivity.kt @@ -0,0 +1,119 @@ +/* + * Copyright 2025 RethinkDNS and its authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.celzero.bravedns.ui.activity + +import Logger +import Logger.LOG_TAG_UI +import android.content.Context +import android.content.res.Configuration +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import by.kirich1409.viewbindingdelegate.viewBinding +import com.celzero.bravedns.R +import com.celzero.bravedns.adapter.RpnCountriesAdapter +import com.celzero.bravedns.databinding.ActivityRpnCountriesBinding +import com.celzero.bravedns.rpnproxy.RegionalWgConf +import com.celzero.bravedns.rpnproxy.RpnProxyManager +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.util.Themes +import com.celzero.bravedns.util.Utilities.isAtleastQ +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koin.android.ext.android.inject + +class RpnCountriesActivity: AppCompatActivity(R.layout.activity_rpn_countries) { + private val b by viewBinding(ActivityRpnCountriesBinding::bind) + private val persistentState by inject() + + //private val proxyCountriesAdapter = ProxyCountriesAdapter() + private val proxyCountries = mutableListOf() + private val selectedCountries = mutableSetOf() + + companion object { + private const val TAG = "RpncUi" + } + + private fun Context.isDarkThemeOn(): Boolean { + return resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == + Configuration.UI_MODE_NIGHT_YES + } + + override fun onCreate(savedInstanceState: Bundle?) { + setTheme(Themes.getCurrentTheme(isDarkThemeOn(), persistentState.theme)) + super.onCreate(savedInstanceState) + + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + io { + fetchProxyCountries() + uiCtx { + showProxyCountries() + } + } + } + + private suspend fun fetchProxyCountries() { + val ccs = RpnProxyManager.getProtonUniqueCC() + Logger.v(LOG_TAG_UI, "$TAG fetch proxy countries: ${ccs.size}") + proxyCountries.addAll(ccs) + val selectedCCs = RpnProxyManager.getSelectedCCs() + Logger.v(LOG_TAG_UI, "$TAG selected countries: ${selectedCCs.size}") + selectedCountries.addAll(selectedCCs) + Logger.i(LOG_TAG_UI, "$TAG total cc: ${ccs.size}, selected: ${selectedCCs.size}") + } + + private fun showProxyCountries() { + if (proxyCountries.isEmpty()) { + Logger.v(LOG_TAG_UI, "$TAG no proxy countries available, show err dialog") + showNoProxyCountriesDialog() + return + } + // show proxy countries + val lst = proxyCountries.map { it } + b.rpncList.layoutManager = LinearLayoutManager(this) + val adapter = RpnCountriesAdapter(this, lst, selectedCountries) + b.rpncList.adapter = adapter + } + + private fun showNoProxyCountriesDialog() { + // show alert dialog with no proxy countries + val dialog = MaterialAlertDialogBuilder(this) + .setTitle("No countries available") + .setMessage("No countries available for RPN. Please try again later.") + .setPositiveButton(R.string.dns_info_positive) { _, _ -> + finish() + } + .setCancelable(false) + .create() + dialog.show() + } + + private suspend fun uiCtx(f: suspend () -> Unit) { + withContext(Dispatchers.Main) { f() } + } + + private fun io(f: suspend () -> Unit) { + lifecycleScope.launch(Dispatchers.IO) { f() } + } +} diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/TcpProxyMainActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/TcpProxyMainActivity.kt index e3a4a15be..02845688e 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/activity/TcpProxyMainActivity.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/activity/TcpProxyMainActivity.kt @@ -9,22 +9,23 @@ import android.os.Bundle import android.view.View import android.widget.Toast import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.lifecycleScope import by.kirich1409.viewbindingdelegate.viewBinding import com.celzero.bravedns.R import com.celzero.bravedns.adapter.WgIncludeAppsAdapter import com.celzero.bravedns.data.AppConfig import com.celzero.bravedns.databinding.ActivityTcpProxyBinding +import com.celzero.bravedns.rpnproxy.RpnProxyManager +import com.celzero.bravedns.rpnproxy.RpnProxyManager.WARP_ID import com.celzero.bravedns.service.PersistentState import com.celzero.bravedns.service.ProxyManager import com.celzero.bravedns.service.TcpProxyHelper import com.celzero.bravedns.service.WireguardManager -import com.celzero.bravedns.service.WireguardManager.SEC_WARP_ID -import com.celzero.bravedns.service.WireguardManager.WARP_ID -import com.celzero.bravedns.service.WireguardManager.isWarpWorking import com.celzero.bravedns.ui.dialog.WgIncludeAppsDialog import com.celzero.bravedns.util.Themes import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.isAtleastQ import com.celzero.bravedns.viewmodel.ProxyAppsMappingViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -42,6 +43,12 @@ class TcpProxyMainActivity : AppCompatActivity(R.layout.activity_tcp_proxy) { override fun onCreate(savedInstanceState: Bundle?) { setTheme(Themes.getCurrentTheme(isDarkThemeOn(), persistentState.theme)) super.onCreate(savedInstanceState) + + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } init() setupClickListeners() } @@ -92,8 +99,8 @@ class TcpProxyMainActivity : AppCompatActivity(R.layout.activity_tcp_proxy) { private fun displayWarpStatus() { io { uiCtx { - val config = WireguardManager.getWarpConfig() - val isActive = WireguardManager.getConfigFilesById(WARP_ID)?.isActive + val config = RpnProxyManager.getWarpConfig() + val isActive = false if (config == null) { b.warpStatus.text = "Fetch from server" // getString(R.string.tcp_proxy_description) @@ -168,23 +175,7 @@ class TcpProxyMainActivity : AppCompatActivity(R.layout.activity_tcp_proxy) { } b.enableUdpRelay.setOnCheckedChangeListener { _, b -> - if (b) { - io { - val alreadyDownloaded = WireguardManager.isSecWarpAvailable() - if (alreadyDownloaded) { - val cf = WireguardManager.getConfigFilesById(SEC_WARP_ID) ?: return@io - WireguardManager.enableConfig(cf) - } else { - createConfigOrShowErrorLayout() - } - } - } else { - io { - val cf = WireguardManager.getConfigFilesById(SEC_WARP_ID) ?: return@io - WireguardManager.disableConfig(cf) - } - } } b.warpSwitch.setOnCheckedChangeListener { _, checked -> @@ -248,24 +239,6 @@ class TcpProxyMainActivity : AppCompatActivity(R.layout.activity_tcp_proxy) { startActivity(intent) } - private suspend fun createConfigOrShowErrorLayout() { - val works = isWarpWorking() - if (works) { - fetchWarpConfigFromServer() - } else { - showConfigCreationError() - } - } - - private suspend fun fetchWarpConfigFromServer() { - val config = WireguardManager.getNewWarpConfig(SEC_WARP_ID) - Logger.i(Logger.LOG_TAG_PROXY, "new config from server: ${config?.getName()}") - if (config == null) { - showConfigCreationError() - return - } - } - private suspend fun showConfigCreationError() { uiCtx { Utilities.showToastUiCentered( diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/TunnelSettingsActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/TunnelSettingsActivity.kt index 6c3c466a8..9fcfe7aa4 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/activity/TunnelSettingsActivity.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/activity/TunnelSettingsActivity.kt @@ -15,18 +15,31 @@ */ package com.celzero.bravedns.ui.activity +import Logger +import Logger.LOG_TAG_UI import android.content.Context import android.content.res.Configuration +import android.graphics.drawable.Drawable +import android.net.NetworkCapabilities import android.os.Bundle +import android.view.LayoutInflater import android.view.View import android.widget.CompoundButton +import android.widget.ProgressBar import android.widget.Toast import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.AppCompatEditText +import androidx.appcompat.widget.AppCompatImageView +import androidx.appcompat.widget.AppCompatTextView +import androidx.core.content.ContextCompat +import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.lifecycleScope import by.kirich1409.viewbindingdelegate.viewBinding import com.celzero.bravedns.R import com.celzero.bravedns.data.AppConfig import com.celzero.bravedns.databinding.ActivityTunnelSettingsBinding +import com.celzero.bravedns.rpnproxy.RpnProxyManager +import com.celzero.bravedns.service.ConnectionMonitor import com.celzero.bravedns.service.PersistentState import com.celzero.bravedns.service.VpnController import com.celzero.bravedns.util.Constants @@ -34,7 +47,16 @@ import com.celzero.bravedns.util.InternetProtocol import com.celzero.bravedns.util.Themes import com.celzero.bravedns.util.UIUtils import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.isAtleastQ +import com.celzero.bravedns.util.Utilities.showToastUiCentered +import com.google.android.material.chip.Chip import com.google.android.material.dialog.MaterialAlertDialogBuilder +import inet.ipaddr.IPAddress.IPVersion +import inet.ipaddr.IPAddressString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.koin.android.ext.android.inject import java.util.concurrent.TimeUnit @@ -46,6 +68,13 @@ class TunnelSettingsActivity : AppCompatActivity(R.layout.activity_tunnel_settin override fun onCreate(savedInstanceState: Bundle?) { setTheme(Themes.getCurrentTheme(isDarkThemeOn(), persistentState.theme)) super.onCreate(savedInstanceState) + + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + initView() setupClickListeners() } @@ -71,6 +100,8 @@ class TunnelSettingsActivity : AppCompatActivity(R.layout.activity_tunnel_settin b.settingsActivityLanTrafficSwitch.isChecked = persistentState.privateIps // connectivity check b.settingsActivityConnectivityChecksSwitch.isChecked = persistentState.connectivityChecks + // show ping ips + b.settingsActivityPingIpsBtn.visibility = if (persistentState.connectivityChecks) View.VISIBLE else View.GONE // exclude apps in proxy b.settingsActivityExcludeProxyAppsSwitch.isChecked = !persistentState.excludeAppsInProxy // for protocol translation, enable only on DNS/DNS+Firewall mode @@ -81,6 +112,10 @@ class TunnelSettingsActivity : AppCompatActivity(R.layout.activity_tunnel_settin b.settingsActivityPtransSwitch.isChecked = false } + b.settingsActivityMobileMeteredSwitch.isChecked = persistentState.treatOnlyMobileNetworkAsMetered + + b.settingsFailOpenSwitch.isChecked = persistentState.failOpenOnNoNetwork + displayInternetProtocolUi() displayRethinkInRethinkUi() } @@ -126,6 +161,7 @@ class TunnelSettingsActivity : AppCompatActivity(R.layout.activity_tunnel_settin getString(R.string.settings_network_all_networks) ) alertBuilder.setMessage(msg) + alertBuilder.setCancelable(false) alertBuilder.setPositiveButton(getString(R.string.lbl_proceed)) { dialog, _ -> dialog.dismiss() b.settingsActivityAllNetworkSwitch.isChecked = true @@ -196,7 +232,7 @@ class TunnelSettingsActivity : AppCompatActivity(R.layout.activity_tunnel_settin persistentState.protocolTranslationType = isSelected } else { b.settingsActivityPtransSwitch.isChecked = false - Utilities.showToastUiCentered( + showToastUiCentered( this, getString(R.string.settings_protocol_translation_dns_inactive), Toast.LENGTH_SHORT @@ -213,10 +249,45 @@ class TunnelSettingsActivity : AppCompatActivity(R.layout.activity_tunnel_settin b.settingsActivityConnectivityChecksSwitch.setOnCheckedChangeListener { _, isChecked -> persistentState.connectivityChecks = isChecked + if (isChecked) { + b.settingsActivityPingIpsBtn.visibility = View.VISIBLE + } else { + b.settingsActivityPingIpsBtn.visibility = View.GONE + } + } + + b.settingsActivityPingIpsBtn.setOnClickListener { + showPingIpsDialog() + } + + b.settingsActivityMobileMeteredSwitch.setOnCheckedChangeListener { _, isChecked -> + persistentState.treatOnlyMobileNetworkAsMetered = isChecked + } + + b.settingsActivityMobileMeteredRl.setOnClickListener { + b.settingsActivityMobileMeteredSwitch.isChecked = + !b.settingsActivityMobileMeteredSwitch.isChecked + } + + b.settingsFailOpenSwitch.setOnCheckedChangeListener { _, isChecked -> + persistentState.failOpenOnNoNetwork = isChecked + } + + b.settingsFailOpenRl.setOnClickListener { + b.settingsFailOpenSwitch.isChecked = !b.settingsFailOpenSwitch.isChecked } } private fun showDefaultDnsDialog() { + if (RpnProxyManager.isRpnActive()) { + showToastUiCentered( + this, + getString(R.string.fallback_rplus_toast), + Toast.LENGTH_SHORT + ) + return + } + val alertBuilder = MaterialAlertDialogBuilder(this) alertBuilder.setTitle(getString(R.string.settings_default_dns_heading)) val items = Constants.DEFAULT_DNS_LIST.map { it.name }.toTypedArray() @@ -233,6 +304,256 @@ class TunnelSettingsActivity : AppCompatActivity(R.layout.activity_tunnel_settin alertBuilder.create().show() } + private fun showPingIpsDialog() { + val alertBuilder = MaterialAlertDialogBuilder(this) + val inflater = LayoutInflater.from(this) + val dialogView = inflater.inflate(R.layout.dialog_input_ips, null) + alertBuilder.setView(dialogView) + alertBuilder.setCancelable(false) + + val protocols = VpnController.protocols() + + val proto4 = dialogView.findViewById(R.id.protocol_v4) + val proto6 = dialogView.findViewById(R.id.protocol_v6) + + val ip41 = dialogView.findViewById(R.id.ipv4_address_1) + val progress41 = dialogView.findViewById(R.id.progress_ipv4_1) + val status41 = dialogView.findViewById(R.id.status_ipv4_1) + + // Repeat for other IP address fields + val ip42 = dialogView.findViewById(R.id.ipv4_address_2) + val progress42 = dialogView.findViewById(R.id.progress_ipv4_2) + val status42 = dialogView.findViewById(R.id.status_ipv4_2) + + val ip43 = dialogView.findViewById(R.id.ipv4_address_3) + val progress43 = dialogView.findViewById(R.id.progress_ipv4_3) + val status43 = dialogView.findViewById(R.id.status_ipv4_3) + + val ip61 = dialogView.findViewById(R.id.ipv6_address_1) + val progress61 = dialogView.findViewById(R.id.progress_ipv6_1) + val status61 = dialogView.findViewById(R.id.status_ipv6_1) + + val ip62 = dialogView.findViewById(R.id.ipv6_address_2) + val progress62 = dialogView.findViewById(R.id.progress_ipv6_2) + val status62 = dialogView.findViewById(R.id.status_ipv6_2) + + val ip63 = dialogView.findViewById(R.id.ipv6_address_3) + val progress63 = dialogView.findViewById(R.id.progress_ipv6_3) + val status63 = dialogView.findViewById(R.id.status_ipv6_3) + + val defaultDrawable = ContextCompat.getDrawable(this, R.drawable.edittext_default) + val errorDrawable = ContextCompat.getDrawable(this, R.drawable.edittext_error) + + val saveBtn: AppCompatTextView = dialogView.findViewById(R.id.save_button) + val testBtn: AppCompatImageView = dialogView.findViewById(R.id.test_button) + val cancelBtn: AppCompatTextView = dialogView.findViewById(R.id.cancel_button) + val resetChip: Chip = dialogView.findViewById(R.id.reset_chip) + + saveBtn.text = getString(R.string.lbl_save).uppercase() + cancelBtn.text = getString(R.string.lbl_cancel).uppercase() + + val errorMsg: AppCompatTextView = dialogView.findViewById(R.id.error_message) + + val items4 = persistentState.pingv4Ips.split(",").toTypedArray() + val items6 = persistentState.pingv6Ips.split(",").toTypedArray() + + if (protocols.contains("IPv4")) { + proto4.setImageResource(R.drawable.ic_tick) + } else { + proto4.setImageResource(R.drawable.ic_cross_accent) + } + + if (protocols.contains("IPv6")) { + proto6.setImageResource(R.drawable.ic_tick) + } else { + proto6.setImageResource(R.drawable.ic_cross_accent) + } + + ip41.setText(items4.getOrNull(0) ?: "") + ip42.setText(items4.getOrNull(1) ?: "") + ip43.setText(items4.getOrNull(2) ?: "") + + ip61.setText(items6.getOrNull(0) ?: "") + ip62.setText(items6.getOrNull(1) ?: "") + ip63.setText(items6.getOrNull(2) ?: "") + + val dialog = alertBuilder.create() + + resetChip.setOnClickListener { + // reset to default values + ip41.setText(Constants.ip4probes[0]) + ip42.setText(Constants.ip4probes[1]) + ip43.setText(Constants.ip4probes[2]) + ip61.setText(Constants.ip6probes[0]) + ip62.setText(Constants.ip6probes[1]) + ip63.setText(Constants.ip6probes[2]) + } + + testBtn.setOnClickListener { + try { + progress41.visibility = View.VISIBLE + progress42.visibility = View.VISIBLE + progress43.visibility = View.VISIBLE + progress61.visibility = View.VISIBLE + progress62.visibility = View.VISIBLE + progress63.visibility = View.VISIBLE + + io { + val valid41 = isReachable(ip41.text.toString()) + val valid42 = isReachable(ip42.text.toString()) + val valid43 = isReachable(ip43.text.toString()) + + val valid61 = isReachable(ip61.text.toString()) + val valid62 = isReachable(ip62.text.toString()) + val valid63 = isReachable(ip63.text.toString()) + + uiCtx { + if (!dialogView.isShown) return@uiCtx + + progress41.visibility = View.GONE + progress42.visibility = View.GONE + progress43.visibility = View.GONE + progress61.visibility = View.GONE + progress62.visibility = View.GONE + progress63.visibility = View.GONE + + status41.visibility = View.VISIBLE + status42.visibility = View.VISIBLE + status43.visibility = View.VISIBLE + status61.visibility = View.VISIBLE + status62.visibility = View.VISIBLE + status63.visibility = View.VISIBLE + + status41.setImageDrawable(getImgRes(valid41)) + status42.setImageDrawable(getImgRes(valid42)) + status43.setImageDrawable(getImgRes(valid43)) + status61.setImageDrawable(getImgRes(valid61)) + status62.setImageDrawable(getImgRes(valid62)) + status63.setImageDrawable(getImgRes(valid63)) + } + } + } catch (e: Exception) { + Logger.e(LOG_TAG_UI, "err on ip ping: ${e.message}", e) + } + } + + cancelBtn.setOnClickListener { + dialog.dismiss() + } + + saveBtn.setOnClickListener { + try { + val valid41 = isValidIp(ip41.text.toString(), IPVersion.IPV4) + val valid42 = isValidIp(ip42.text.toString(), IPVersion.IPV4) + val valid43 = isValidIp(ip43.text.toString(), IPVersion.IPV4) + + val valid61 = isValidIp(ip61.text.toString(), IPVersion.IPV6) + val valid62 = isValidIp(ip62.text.toString(), IPVersion.IPV6) + val valid63 = isValidIp(ip63.text.toString(), IPVersion.IPV6) + + // mark the edit text background as red if the ip is invalid + ip41.background = if (valid41) defaultDrawable else errorDrawable + ip42.background = if (valid42) defaultDrawable else errorDrawable + ip43.background = if (valid43) defaultDrawable else errorDrawable + ip61.background = if (valid61) defaultDrawable else errorDrawable + ip62.background = if (valid62) defaultDrawable else errorDrawable + ip63.background = if (valid63) defaultDrawable else errorDrawable + + if (!valid41 || !valid42 || !valid43 || !valid61 || !valid62 || !valid63) { + errorMsg.visibility = View.VISIBLE + errorMsg.text = getString(R.string.cd_dns_proxy_error_text_1) + return@setOnClickListener + } else { + errorMsg.visibility = View.VISIBLE + errorMsg.text = "" + } + + val ip4 = listOf(ip41.text.toString(), ip42.text.toString(), ip43.text.toString()) + val ip6 = listOf(ip61.text.toString(), ip62.text.toString(), ip63.text.toString()) + + val isSame = persistentState.pingv4Ips == ip4.joinToString(",") && + persistentState.pingv6Ips == ip6.joinToString(",") + + if (isSame) { + dialog.dismiss() + return@setOnClickListener + } + + persistentState.pingv4Ips = ip4.joinToString(",") + persistentState.pingv6Ips = ip6.joinToString(",") + Utilities.showToastUiCentered( + this, + getString(R.string.config_add_success_toast), + Toast.LENGTH_LONG + ) + notifyConnectionMonitor() + + Logger.i(LOG_TAG_UI, "ping ips: ${persistentState.pingv4Ips}, ${persistentState.pingv6Ips}") + dialog.dismiss() + } catch (e: Exception) { + Logger.e(LOG_TAG_UI, "err on ip save: ${e.message}", e) + // reset persistent state to the previous value + persistentState.pingv4Ips = Constants.ip4probes.joinToString(",") + persistentState.pingv6Ips = Constants.ip6probes.joinToString(",") + } + } + + dialog.show() + } + + private fun notifyConnectionMonitor() { + // change in ips, inform connection monitor to recheck the connectivity + io { VpnController.notifyConnectionMonitor() } + } + + private fun getImgRes(probeResult: ConnectionMonitor.ProbeResult?): Drawable? { + val failureDrawable = ContextCompat.getDrawable(this, R.drawable.ic_cross_accent) + + if (probeResult == null) return failureDrawable + + if (!probeResult.ok) return failureDrawable + + val cap = probeResult.capabilities ?: return failureDrawable + + val a = if (cap.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { + R.drawable.ic_firewall_wifi_on // wifi + } else if (cap.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) { + R.drawable.ic_firewall_data_on + } else { + R.drawable.ic_tick + } + + val successDrawable = ContextCompat.getDrawable(this, R.drawable.ic_tick) + + return ContextCompat.getDrawable(this, a) ?: successDrawable + } + + private suspend fun isReachable(ip: String): ConnectionMonitor.ProbeResult? { + delay(500) + return try { + val res = VpnController.probeIp(ip) + Logger.d(LOG_TAG_UI, "probe res: ${res?.ok}, ${res?.ip}, ${res?.capabilities}") + res + } catch (e: Exception) { + Logger.d(LOG_TAG_UI, "err on ip ping(isReachable): ${e.message}") + null + } + } + + private fun isValidIp(ipString: String, type: IPVersion): Boolean { + try { + if (type.isIPv4) { + return IPAddressString(ipString).toAddress().isIPv4 + } + if (type.isIPv6) { + return IPAddressString(ipString).toAddress().isIPv6 + } + } catch (e: Exception) { + Logger.i(LOG_TAG_UI, "err on ip validation: ${e.message}") + } + return false + } + private fun displayInternetProtocolUi() { b.settingsActivityIpRl.isEnabled = true when (persistentState.internetProtocolType) { @@ -244,6 +565,7 @@ class TunnelSettingsActivity : AppCompatActivity(R.layout.activity_tunnel_settin ) b.settingsActivityPtransRl.visibility = View.GONE b.settingsActivityConnectivityChecksRl.visibility = View.GONE + b.settingsActivityPingIpsBtn.visibility = View.GONE } InternetProtocol.IPv6.id -> { b.genSettingsIpDesc.text = @@ -253,6 +575,7 @@ class TunnelSettingsActivity : AppCompatActivity(R.layout.activity_tunnel_settin ) b.settingsActivityPtransRl.visibility = View.VISIBLE b.settingsActivityConnectivityChecksRl.visibility = View.GONE + b.settingsActivityPingIpsBtn.visibility = View.GONE } InternetProtocol.IPv46.id -> { b.genSettingsIpDesc.text = @@ -262,6 +585,11 @@ class TunnelSettingsActivity : AppCompatActivity(R.layout.activity_tunnel_settin ) b.settingsActivityPtransRl.visibility = View.GONE b.settingsActivityConnectivityChecksRl.visibility = View.VISIBLE + if (persistentState.connectivityChecks) { + b.settingsActivityPingIpsBtn.visibility = View.VISIBLE + } else { + b.settingsActivityPingIpsBtn.visibility = View.GONE + } } else -> { b.genSettingsIpDesc.text = @@ -271,6 +599,7 @@ class TunnelSettingsActivity : AppCompatActivity(R.layout.activity_tunnel_settin ) b.settingsActivityPtransRl.visibility = View.GONE b.settingsActivityConnectivityChecksRl.visibility = View.GONE + b.settingsActivityPingIpsBtn.visibility = View.GONE } } } @@ -314,17 +643,14 @@ class TunnelSettingsActivity : AppCompatActivity(R.layout.activity_tunnel_settin if (isLockdown) { b.settingsActivityVpnLockdownDesc.visibility = View.VISIBLE b.settingsActivityAllowBypassRl.alpha = 0.5f - b.settingsActivityLanTrafficRl.alpha = 0.5f b.settingsActivityExcludeProxyAppsRl.alpha = 0.5f } else { b.settingsActivityVpnLockdownDesc.visibility = View.GONE b.settingsActivityAllowBypassRl.alpha = 1f - b.settingsActivityLanTrafficRl.alpha = 1f b.settingsActivityExcludeProxyAppsRl.alpha = 1f } b.settingsActivityAllowBypassSwitch.isEnabled = !isLockdown b.settingsActivityAllowBypassRl.isEnabled = !isLockdown - b.settingsActivityLanTrafficSwitch.isEnabled = !isLockdown b.settingsActivityLanTrafficRl.isEnabled = !isLockdown b.settingsActivityExcludeProxyAppsSwitch.isEnabled = !isLockdown b.settingsActivityExcludeProxyAppsRl.isEnabled = !isLockdown @@ -335,4 +661,12 @@ class TunnelSettingsActivity : AppCompatActivity(R.layout.activity_tunnel_settin Utilities.delay(ms, lifecycleScope) { for (v in views) v.isEnabled = true } } + + private fun io(fn: suspend () -> Unit) { + lifecycleScope.launch(Dispatchers.IO) { fn() } + } + + private suspend fun uiCtx(f: suspend () -> Unit) { + withContext(Dispatchers.Main) { f() } + } } diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/UniversalFirewallSettingsActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/UniversalFirewallSettingsActivity.kt index d900ca169..cef1e422b 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/activity/UniversalFirewallSettingsActivity.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/activity/UniversalFirewallSettingsActivity.kt @@ -23,27 +23,53 @@ import android.content.Intent import android.content.res.Configuration import android.os.Bundle import android.provider.Settings +import android.view.View import android.widget.CompoundButton import android.widget.Toast import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope import by.kirich1409.viewbindingdelegate.viewBinding import com.celzero.bravedns.R +import com.celzero.bravedns.database.ConnectionTracker +import com.celzero.bravedns.database.ConnectionTrackerRepository import com.celzero.bravedns.databinding.ActivityUniversalFirewallSettingsBinding +import com.celzero.bravedns.service.FirewallRuleset import com.celzero.bravedns.service.PersistentState import com.celzero.bravedns.util.BackgroundAccessibilityService +import com.celzero.bravedns.util.Constants import com.celzero.bravedns.util.Themes import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.isAtleastQ import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.koin.android.ext.android.inject class UniversalFirewallSettingsActivity : AppCompatActivity(R.layout.activity_universal_firewall_settings) { private val b by viewBinding(ActivityUniversalFirewallSettingsBinding::bind) private val persistentState by inject() + private val connTrackerRepository by inject() + + private lateinit var blockedUniversalRules : List + + companion object { + const val RULES_SEARCH_ID = "R:" + } override fun onCreate(savedInstanceState: Bundle?) { setTheme(Themes.getCurrentTheme(isDarkThemeOn(), persistentState.theme)) super.onCreate(savedInstanceState) + + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } init() } @@ -69,6 +95,7 @@ class UniversalFirewallSettingsActivity : b.firewallUnivLockdownCheck.isChecked = persistentState.getUniversalLockdown() setupClickListeners() + updateStats() } private fun setupClickListeners() { @@ -164,6 +191,25 @@ class UniversalFirewallSettingsActivity : b.firewallUnivLockdownTxt.setOnClickListener { b.firewallUnivLockdownCheck.isChecked = !b.firewallUnivLockdownCheck.isChecked } + + // click listener for the stats + b.firewallDeviceLockedRl.setOnClickListener { startActivity(FirewallRuleset.RULE3.id) } + + b.firewallNotInUseRl.setOnClickListener { startActivity(FirewallRuleset.RULE4.id) } + + b.firewallUnknownRl.setOnClickListener { startActivity(FirewallRuleset.RULE5.id) } + + b.firewallUdpRl.setOnClickListener { startActivity(FirewallRuleset.RULE6.id) } + + b.firewallDnsBypassRl.setOnClickListener { startActivity(FirewallRuleset.RULE7.id) } + + b.firewallNewAppRl.setOnClickListener { startActivity(FirewallRuleset.RULE8.id) } + + b.firewallMeteredRl.setOnClickListener { startActivity(FirewallRuleset.RULE1F.id) } + + b.firewallHttpRl.setOnClickListener { startActivity(FirewallRuleset.RULE10.id) } + + b.firewallLockdownRl.setOnClickListener { startActivity(FirewallRuleset.RULE11.id) } } private fun recheckFirewallBackgroundMode(isChecked: Boolean) { @@ -249,6 +295,18 @@ class UniversalFirewallSettingsActivity : } } + private var maxValue: Double = 0.0 + + private fun calculatePercentage(c: Double): Int { + if (maxValue == 0.0) return 0 + if (c > maxValue) { + maxValue = c + return 100 + } + val percentage = (c / maxValue) * 100 + return percentage.toInt() + } + private fun showPermissionAlert() { val builder = MaterialAlertDialogBuilder(this) builder.setTitle(R.string.alert_permission_accessibility) @@ -275,4 +333,155 @@ class UniversalFirewallSettingsActivity : Logger.e(LOG_TAG_FIREWALL, "Failure accessing accessibility settings: ${e.message}", e) } } + + private fun updateStats() { + io { + // get stats for all the firewall rules + // update the UI with the stats + // 1. device locked - 2. background mode - 3. unknown 4. udp 5. dns bypass 6. new app 7. + // metered 8. http 9. universal lockdown + // instead get all the stats in one go and update the UI + blockedUniversalRules = connTrackerRepository.getBlockedUniversalRulesCount() + val deviceLocked = + blockedUniversalRules.filter { it.blockedByRule.contains(FirewallRuleset.RULE3.id) } + val backgroundMode = + blockedUniversalRules.filter { it.blockedByRule.contains(FirewallRuleset.RULE4.id) } + val unknown = + blockedUniversalRules.filter { it.blockedByRule.contains(FirewallRuleset.RULE5.id) } + val udp = + blockedUniversalRules.filter { it.blockedByRule.contains(FirewallRuleset.RULE6.id) } + val dnsBypass = + blockedUniversalRules.filter { it.blockedByRule.contains(FirewallRuleset.RULE7.id) } + val newApp = + blockedUniversalRules.filter { it.blockedByRule.contains(FirewallRuleset.RULE8.id) } + val metered = + blockedUniversalRules.filter { + it.blockedByRule.contains(FirewallRuleset.RULE1F.id) + } + val http = + blockedUniversalRules.filter { + it.blockedByRule.contains(FirewallRuleset.RULE10.id) + } + val universalLockdown = + blockedUniversalRules.filter { + it.blockedByRule.contains(FirewallRuleset.RULE11.id) + } + + val blockedCountList = + listOf( + deviceLocked.size, + backgroundMode.size, + unknown.size, + udp.size, + dnsBypass.size, + newApp.size, + metered.size, + http.size, + universalLockdown.size + ) + + maxValue = blockedCountList.maxOrNull()?.toDouble() ?: 0.0 + + uiCtx { + b.firewallDeviceLockedShimmerLayout.postDelayed( + { + if (!canPerformUiAction()) return@postDelayed + + stopShimmer() + hideShimmer() + + b.deviceLockedProgress.progress = + calculatePercentage(blockedCountList[0].toDouble()) + b.notInUseProgress.progress = + calculatePercentage(blockedCountList[1].toDouble()) + b.unknownProgress.progress = + calculatePercentage(blockedCountList[2].toDouble()) + b.udpProgress.progress = calculatePercentage(blockedCountList[3].toDouble()) + b.dnsBypassProgress.progress = + calculatePercentage(blockedCountList[4].toDouble()) + b.newAppProgress.progress = + calculatePercentage(blockedCountList[5].toDouble()) + b.meteredProgress.progress = + calculatePercentage(blockedCountList[6].toDouble()) + b.httpProgress.progress = + calculatePercentage(blockedCountList[7].toDouble()) + b.lockdownProgress.progress = + calculatePercentage(blockedCountList[8].toDouble()) + + b.firewallDeviceLockedStats.text = deviceLocked.size.toString() + b.firewallNotInUseStats.text = backgroundMode.size.toString() + b.firewallUnknownStats.text = unknown.size.toString() + b.firewallUdpStats.text = udp.size.toString() + b.firewallDnsBypassStats.text = dnsBypass.size.toString() + b.firewallNewAppStats.text = newApp.size.toString() + b.firewallMeteredStats.text = metered.size.toString() + b.firewallHttpStats.text = http.size.toString() + b.firewallLockdownStats.text = universalLockdown.size.toString() + }, + 500 + ) + } + } + } + + private fun canPerformUiAction(): Boolean { + return !isFinishing && + !isDestroyed && + lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED) && + !isChangingConfigurations + } + + override fun onPause() { + super.onPause() + stopShimmer() + } + + private fun stopShimmer() { + if (!canPerformUiAction()) return + + b.firewallUdpShimmerLayout.stopShimmer() + b.firewallDeviceLockedShimmerLayout.stopShimmer() + b.firewallNotInUseShimmerLayout.stopShimmer() + b.firewallUnknownShimmerLayout.stopShimmer() + b.firewallDnsBypassShimmerLayout.stopShimmer() + b.firewallNewAppShimmerLayout.stopShimmer() + b.firewallMeteredShimmerLayout.stopShimmer() + b.firewallHttpShimmerLayout.stopShimmer() + b.firewallLockdownShimmerLayout.stopShimmer() + } + + private fun hideShimmer() { + if (!canPerformUiAction()) return + + b.firewallUdpShimmerLayout.visibility = View.GONE + b.firewallDeviceLockedShimmerLayout.visibility = View.GONE + b.firewallNotInUseShimmerLayout.visibility = View.GONE + b.firewallUnknownShimmerLayout.visibility = View.GONE + b.firewallDnsBypassShimmerLayout.visibility = View.GONE + b.firewallNewAppShimmerLayout.visibility = View.GONE + b.firewallMeteredShimmerLayout.visibility = View.GONE + b.firewallHttpShimmerLayout.visibility = View.GONE + b.firewallLockdownShimmerLayout.visibility = View.GONE + } + + private fun startActivity(rule: String?) { + if (rule.isNullOrEmpty()) return + + // if the rules are not blocked, then no need to start the activity + val size = blockedUniversalRules.filter { it.blockedByRule.contains(rule) }.size + if (size == 0) return + + val intent = Intent(this, NetworkLogsActivity::class.java) + val searchParam = RULES_SEARCH_ID + rule + intent.putExtra(Constants.SEARCH_QUERY, searchParam) + startActivity(intent) + } + + private fun io(f: suspend () -> Unit): Job { + return lifecycleScope.launch(Dispatchers.IO) { f() } + } + + private suspend fun uiCtx(f: suspend () -> Unit) { + withContext(Dispatchers.Main) { f() } + } } diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/WelcomeActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/WelcomeActivity.kt index e0a1238e0..edbe4e059 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/activity/WelcomeActivity.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/activity/WelcomeActivity.kt @@ -19,18 +19,19 @@ import android.content.Context import android.content.Intent import android.content.res.Configuration import android.graphics.Color +import android.os.Build import android.os.Bundle +import android.text.Html +import android.text.Spanned import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.view.Window import android.view.WindowManager -import android.widget.LinearLayout import android.widget.TextView -import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AppCompatActivity import androidx.core.text.HtmlCompat +import androidx.core.view.WindowInsetsControllerCompat import androidx.viewpager.widget.PagerAdapter import androidx.viewpager.widget.ViewPager import by.kirich1409.viewbindingdelegate.viewBinding @@ -39,41 +40,43 @@ import com.celzero.bravedns.databinding.ActivityWelcomeBinding import com.celzero.bravedns.service.PersistentState import com.celzero.bravedns.ui.HomeScreenActivity import com.celzero.bravedns.util.Themes +import com.celzero.bravedns.util.Utilities.isAtleastQ import org.koin.android.ext.android.inject class WelcomeActivity : AppCompatActivity(R.layout.activity_welcome) { + private val b by viewBinding(ActivityWelcomeBinding::bind) private lateinit var dots: Array - internal val layout: IntArray = intArrayOf(R.layout.welcome_slide2, R.layout.welcome_slide1) - - private lateinit var myPagerAdapter: PagerAdapter + private val layouts: IntArray = intArrayOf( + R.layout.welcome_slide1, + R.layout.welcome_slide2, + R.layout.welcome_slide3, + R.layout.welcome_slide4 + ) + private var myPagerAdapter: MyPagerAdapter? = null private val persistentState by inject() override fun onCreate(savedInstanceState: Bundle?) { setTheme(Themes.getCurrentTheme(isDarkThemeOn(), persistentState.theme)) super.onCreate(savedInstanceState) + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + + // Add bottom dots addBottomDots(0) + + // Change status bar color changeStatusBarColor() + // Initialize adapter myPagerAdapter = MyPagerAdapter() + // Set up ViewPager b.viewPager.adapter = myPagerAdapter - - b.btnSkip.setOnClickListener { launchHomeScreen() } - - b.btnNext.setOnClickListener { - val currentItem = getItem() - // size and count() are almost always equivalent. However some lazy Seq cannot know - // their size until being fulfilled so size will be undefined for those cases and - // calling count() will fulfill the lazy Seq to determine its size. - if (currentItem + 1 >= layout.count()) { - launchHomeScreen() - } else { - b.viewPager.currentItem = currentItem + 1 - } - } - b.viewPager.addOnPageChangeListener( object : ViewPager.OnPageChangeListener { override fun onPageScrollStateChanged(state: Int) {} @@ -82,70 +85,74 @@ class WelcomeActivity : AppCompatActivity(R.layout.activity_welcome) { position: Int, positionOffset: Float, positionOffsetPixels: Int - ) {} + ) { + } override fun onPageSelected(position: Int) { addBottomDots(position) - if (position >= layout.count() - 1) { + + // Change the next button text 'NEXT' / 'GOT IT' + if (position >= layouts.count() - 1) { + // Last page. Make button text to GOT IT b.btnNext.text = getString(R.string.finish) - b.btnNext.visibility = View.VISIBLE b.btnSkip.visibility = View.INVISIBLE } else { + // Still pages are left + b.btnNext.text = getString(R.string.next) b.btnSkip.visibility = View.VISIBLE - b.btnNext.visibility = View.INVISIBLE } } } ) - // Note that you shouldn't override the onBackPressed() as that will make the - // onBackPressedDispatcher callback not to fire - onBackPressedDispatcher.addCallback( - this /* lifecycle owner */, - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - // Back is pressed... - return - } + // Set up button click listeners + b.btnSkip.setOnClickListener { launchHomeScreen() } + b.btnNext.setOnClickListener { + // Check if user is on last page, then go to home screen + val currentItem = getItem() + if (currentItem + 1 >= layouts.count()) { + launchHomeScreen() + } else { + // Otherwise go to next page + b.viewPager.currentItem = currentItem + 1 } - ) + } } - private fun Context.isDarkThemeOn(): Boolean { + private fun isDarkThemeOn(): Boolean { return resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == - Configuration.UI_MODE_NIGHT_YES - } - - private fun changeStatusBarColor() { - val window: Window = window - window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) - window.statusBarColor = Color.TRANSPARENT + Configuration.UI_MODE_NIGHT_YES } private fun addBottomDots(currentPage: Int) { - dots = arrayOfNulls(layout.count()) + dots = arrayOfNulls(layouts.size) val colorActive = resources.getIntArray(R.array.array_dot_active) val colorInActive = resources.getIntArray(R.array.array_dot_inactive) b.layoutDots.removeAllViews() + for (i in dots.indices) { dots[i] = TextView(this) - dots[i]?.layoutParams = - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ) - dots[i]?.text = HtmlCompat.fromHtml("•", HtmlCompat.FROM_HTML_MODE_LEGACY) + dots[i]?.text = updateHtmlEncodedText("•") dots[i]?.setTextSize(TypedValue.COMPLEX_UNIT_SP, 30F) dots[i]?.setTextColor(colorInActive[currentPage]) b.layoutDots.addView(dots[i]) } + if (dots.isNotEmpty()) { dots[currentPage]?.setTextColor(colorActive[currentPage]) } } + fun updateHtmlEncodedText(text: String): Spanned { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + Html.fromHtml(text, Html.FROM_HTML_MODE_LEGACY) + } else { + HtmlCompat.fromHtml(text, HtmlCompat.FROM_HTML_MODE_LEGACY) + } + } + private fun getItem(): Int { return b.viewPager.currentItem } @@ -156,24 +163,30 @@ class WelcomeActivity : AppCompatActivity(R.layout.activity_welcome) { finish() } - inner class MyPagerAdapter : PagerAdapter() { - private lateinit var layoutInflater: LayoutInflater + private fun changeStatusBarColor() { + window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) + window.statusBarColor = Color.TRANSPARENT + } + // ViewPager adapter + inner class MyPagerAdapter : PagerAdapter() { override fun isViewFromObject(view: View, `object`: Any): Boolean { return view == `object` } override fun getCount(): Int { - return layout.count() + return layouts.count() } override fun instantiateItem(container: ViewGroup, position: Int): Any { - layoutInflater = getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater - val view: View = layoutInflater.inflate(layout[position], container, false) + val layoutInflater = getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater + val view = layoutInflater.inflate(layouts[position], container, false) container.addView(view) return view } - override fun destroyItem(container: ViewGroup, position: Int, `object`: Any) {} + override fun destroyItem(container: ViewGroup, position: Int, `object`: Any) { + container.removeView(`object` as View) + } } } diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/WgConfigDetailActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/WgConfigDetailActivity.kt index ee88283a4..e4f625e5e 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/activity/WgConfigDetailActivity.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/activity/WgConfigDetailActivity.kt @@ -21,28 +21,46 @@ import android.content.Context import android.content.Intent import android.content.res.Configuration import android.os.Bundle +import android.text.format.DateUtils import android.view.View import android.widget.Toast import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager +import com.celzero.firestack.backend.RouterStats import by.kirich1409.viewbindingdelegate.viewBinding import com.celzero.bravedns.R import com.celzero.bravedns.adapter.WgIncludeAppsAdapter import com.celzero.bravedns.adapter.WgPeersAdapter import com.celzero.bravedns.databinding.ActivityWgDetailBinding +import com.celzero.bravedns.net.doh.Transaction import com.celzero.bravedns.service.PersistentState -import com.celzero.bravedns.service.ProxyManager +import com.celzero.bravedns.service.ProxyManager.ID_WG_BASE +import com.celzero.bravedns.service.VpnController import com.celzero.bravedns.service.WireguardManager +import com.celzero.bravedns.service.WireguardManager.ERR_CODE_OTHER_WG_ACTIVE +import com.celzero.bravedns.service.WireguardManager.ERR_CODE_VPN_NOT_ACTIVE +import com.celzero.bravedns.service.WireguardManager.ERR_CODE_VPN_NOT_FULL +import com.celzero.bravedns.service.WireguardManager.ERR_CODE_WG_INVALID import com.celzero.bravedns.service.WireguardManager.INVALID_CONF_ID +import com.celzero.bravedns.service.WireguardManager.WG_HANDSHAKE_TIMEOUT +import com.celzero.bravedns.service.WireguardManager.WG_UPTIME_THRESHOLD +import com.celzero.bravedns.ui.activity.NetworkLogsActivity.Companion.RULES_SEARCH_ID_WIREGUARD import com.celzero.bravedns.ui.dialog.WgAddPeerDialog +import com.celzero.bravedns.ui.dialog.WgHopDialog import com.celzero.bravedns.ui.dialog.WgIncludeAppsDialog +import com.celzero.bravedns.util.Constants import com.celzero.bravedns.util.Themes import com.celzero.bravedns.util.UIUtils +import com.celzero.bravedns.util.UIUtils.fetchColor import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.isAtleastQ +import com.celzero.bravedns.util.Utilities.tos import com.celzero.bravedns.viewmodel.ProxyAppsMappingViewModel import com.celzero.bravedns.wireguard.Config import com.celzero.bravedns.wireguard.Peer +import com.celzero.bravedns.wireguard.WgHopManager import com.celzero.bravedns.wireguard.WgInterface import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.Dispatchers @@ -79,13 +97,19 @@ class WgConfigDetailActivity : AppCompatActivity(R.layout.activity_wg_detail) { fun isDefault() = this == DEFAULT companion object { - fun fromInt(value: Int) = entries.first { it.value == value } + fun fromInt(value: Int) = entries.firstOrNull { it.value == value } ?: DEFAULT } } override fun onCreate(savedInstanceState: Bundle?) { setTheme(Themes.getCurrentTheme(isDarkThemeOn(), persistentState.theme)) super.onCreate(savedInstanceState) + + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } configId = intent.getIntExtra(WgConfigEditorActivity.INTENT_EXTRA_WG_ID, INVALID_CONF_ID) wgType = WgType.fromInt(intent.getIntExtra(INTENT_EXTRA_WG_TYPE, WgType.DEFAULT.value)) } @@ -102,6 +126,19 @@ class WgConfigDetailActivity : AppCompatActivity(R.layout.activity_wg_detail) { } private fun init() { + if (!VpnController.hasTunnel()) { + Logger.i(LOG_TAG_PROXY, "VPN not active, config may not be available") + Utilities.showToastUiCentered( + this, + ERR_CODE_VPN_NOT_ACTIVE + + getString(R.string.settings_socks5_vpn_disabled_error), + Toast.LENGTH_LONG + ) + finish() + return + } + + b.editBtn.text = getString(R.string.rt_edit_dialog_positive).lowercase() b.globalLockdownTitleTv.text = getString( R.string.two_argument_space, @@ -119,13 +156,17 @@ class WgConfigDetailActivity : AppCompatActivity(R.layout.activity_wg_detail) { b.lockdownRl.visibility = View.VISIBLE b.catchAllRl.visibility = View.VISIBLE b.oneWgInfoTv.visibility = View.GONE + b.hopBtn.visibility = View.VISIBLE + b.useMobileRl.visibility = View.VISIBLE } else if (wgType.isOneWg()) { b.wgHeaderTv.text = getString(R.string.rt_list_simple_btn_txt).replaceFirstChar(Char::titlecase) b.lockdownRl.visibility = View.GONE b.catchAllRl.visibility = View.GONE + b.hopBtn.visibility = View.GONE b.oneWgInfoTv.visibility = View.VISIBLE b.applicationsBtn.isEnabled = false + b.useMobileRl.visibility = View.GONE b.applicationsBtn.text = getString(R.string.one_wg_apps_added) } else { // invalid wireguard type, finish the activity @@ -136,11 +177,10 @@ class WgConfigDetailActivity : AppCompatActivity(R.layout.activity_wg_detail) { val mapping = WireguardManager.getConfigFilesById(configId) if (config == null) { - finish() + showInvalidConfigDialog() return } - // handleWarpConfigView() if (mapping != null) { // if catch all is enabled, disable the add apps button and lockdown b.catchAllCheck.isChecked = mapping.isCatchAll @@ -150,6 +190,7 @@ class WgConfigDetailActivity : AppCompatActivity(R.layout.activity_wg_detail) { b.applicationsBtn.text = getString(R.string.routing_remaining_apps) } b.lockdownCheck.isChecked = mapping.isLockdown + b.useMobileCheck.isChecked = mapping.useOnlyOnMetered } if (shouldObserveAppsCount()) { @@ -159,14 +200,142 @@ class WgConfigDetailActivity : AppCompatActivity(R.layout.activity_wg_detail) { // texts are updated based on the catch all and one-wg } - /*if (config == null && configId == WARP_ID) { - showNewWarpConfigLayout() - return@uiCtx - }*/ - + io { updateStatusUi(config.getId()) } prefillConfig(config) } + private suspend fun updateStatusUi(id: Int) { + val config = WireguardManager.getConfigFilesById(id) + val cid = ID_WG_BASE + id + if (config?.isActive == true) { + val statusPair = VpnController.getProxyStatusById(cid) + val stats = VpnController.getProxyStats(cid) + val ps = UIUtils.ProxyStatus.entries.find { it.id == statusPair.first } + val dnsStatusId = if (persistentState.splitDns) { + VpnController.getDnsStatus(cid) + } else { + null + } + uiCtx { + if (dnsStatusId != null && isDnsError(dnsStatusId)) { + // check for dns failure cases and update the UI + b.interfaceDetailCard.strokeColor = fetchColor(this, R.attr.chipTextNegative) + b.statusText.text = getString(R.string.status_failing) + .replaceFirstChar(Char::titlecase) + } else if (statusPair.first != null) { + val handshakeTime = getHandshakeTime(stats).toString() + val statusText = getIdleStatusText(ps, stats) + .ifEmpty { getStatusText(ps, handshakeTime, stats, statusPair.second) } + b.statusText.text = statusText + } else { + if (statusPair.second.isEmpty()) { + b.statusText.text = + getString(R.string.status_waiting).replaceFirstChar(Char::titlecase) + } else { + val txt = + getString(R.string.status_waiting).replaceFirstChar(Char::titlecase) + "(${statusPair.second})" + b.statusText.text = txt + } + } + val strokeColor = getStrokeColorForStatus(ps, stats) + b.interfaceDetailCard.strokeWidth = 2 + b.interfaceDetailCard.strokeColor = fetchColor(this, strokeColor) + } + } else { + uiCtx { + b.interfaceDetailCard.strokeWidth = 0 + b.statusText.text = + getString(R.string.lbl_disabled).replaceFirstChar(Char::titlecase) + } + } + } + + private fun isDnsError(statusId: Long?): Boolean { + if (statusId == null) return true + + val s = Transaction.Status.fromId(statusId) + return s == Transaction.Status.BAD_QUERY || s == Transaction.Status.BAD_RESPONSE || s == Transaction.Status.NO_RESPONSE || s == Transaction.Status.SEND_FAIL || s == Transaction.Status.CLIENT_ERROR || s == Transaction.Status.INTERNAL_ERROR || s == Transaction.Status.TRANSPORT_ERROR + } + + private fun getStatusText( + status: UIUtils.ProxyStatus?, + handshakeTime: String? = null, + stats: RouterStats?, + errMsg: String? = null + ): String { + if (status == null) { + val txt = if (!errMsg.isNullOrEmpty()) { + getString(R.string.status_waiting) + " ($errMsg)" + } else { + getString(R.string.status_waiting) + } + return txt.replaceFirstChar(Char::titlecase) + } + + val now = System.currentTimeMillis() + val lastOk = stats?.lastOK ?: 0L + val since = stats?.since ?: 0L + if (now - since > WG_UPTIME_THRESHOLD && lastOk == 0L) { + return getString(R.string.status_failing).replaceFirstChar(Char::titlecase) + } + + val baseText = getString(UIUtils.getProxyStatusStringRes(status.id)) + .replaceFirstChar(Char::titlecase) + + return if (stats?.lastOK != 0L && handshakeTime != null) { + getString(R.string.about_version_install_source, baseText, handshakeTime) + } else { + baseText + } + } + + private fun getIdleStatusText(status: UIUtils.ProxyStatus?, stats: RouterStats?): String { + if (status != UIUtils.ProxyStatus.TZZ && status != UIUtils.ProxyStatus.TNT) return "" + if (stats == null || stats.lastOK == 0L) return "" + if (System.currentTimeMillis() - stats.since >= WG_HANDSHAKE_TIMEOUT) return "" + + return getString(R.string.dns_connected).replaceFirstChar(Char::titlecase) + } + + private fun getHandshakeTime(stats: RouterStats?): CharSequence { + if (stats == null) { + return "" + } + if (stats.lastOK == 0L) { + return "" + } + val now = System.currentTimeMillis() + // returns a string describing 'time' as a time relative to 'now' + return DateUtils.getRelativeTimeSpanString( + stats.lastOK, + now, + DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE + ) + } + + private fun getStrokeColorForStatus(status: UIUtils.ProxyStatus?, stats: RouterStats?): Int { + return when (status) { + UIUtils.ProxyStatus.TOK -> if (stats?.lastOK == 0L) return R.attr.chipTextNeutral else R.attr.accentGood + UIUtils.ProxyStatus.TUP, UIUtils.ProxyStatus.TZZ -> R.attr.chipTextNeutral + else -> R.attr.chipTextNegative // TNT, TKO, TEND + } + } + + private fun showInvalidConfigDialog() { + val builder = MaterialAlertDialogBuilder(this) + builder.setTitle(getString(R.string.lbl_wireguard)) + builder.setMessage(getString(R.string.config_invalid_desc)) + builder.setCancelable(false) + builder.setPositiveButton(getString(R.string.fapps_info_dialog_positive_btn)) { _, _ -> + finish() + } + builder.setNeutralButton(getString(R.string.lbl_delete)) { _, _ -> + WireguardManager.deleteConfig(configId) + } + builder.create().show() + } + private fun shouldObserveAppsCount(): Boolean { return !wgType.isOneWg() && !b.catchAllCheck.isChecked } @@ -178,15 +347,10 @@ class WgConfigDetailActivity : AppCompatActivity(R.layout.activity_wg_detail) { if (wgInterface == null) { return } + b.configNameText.visibility = View.VISIBLE b.configNameText.text = config.getName() - b.publicKeyText.text = wgInterface?.getKeyPair()?.getPublicKey()?.base64() + b.configIdText.text = getString(R.string.single_argument_parenthesis, config.getId().toString()) - if (wgInterface?.getAddresses()?.isEmpty() == true) { - b.addressesLabel.visibility = View.GONE - b.addressesText.visibility = View.GONE - } else { - b.addressesText.text = wgInterface?.getAddresses()?.joinToString { it.toString() } - } setPeersAdapter() // show dns servers if in one-wg mode if (wgType.isOneWg()) { @@ -202,11 +366,23 @@ class WgConfigDetailActivity : AppCompatActivity(R.layout.activity_wg_detail) { } b.dnsServersText.text = dns } else { + b.publicKeyLabel.visibility = View.VISIBLE + b.publicKeyText.visibility = View.VISIBLE + b.publicKeyText.text = wgInterface?.getKeyPair()?.getPublicKey()?.base64().tos() b.dnsServersLabel.visibility = View.GONE b.dnsServersText.visibility = View.GONE } - // uncomment this if we want to show the dns servers, listen port and mtu + // uncomment this if we want to show the public key, addresses, listen port and mtu + /*b.publicKeyText.text = wgInterface?.getKeyPair()?.getPublicKey()?.base64() + + if (wgInterface?.getAddresses()?.isEmpty() == true) { + b.addressesLabel.visibility = View.GONE + b.addressesText.visibility = View.GONE + } else { + b.addressesText.text = wgInterface?.getAddresses()?.joinToString { it.toString() } + }*/ + /*if (wgInterface?.dnsServers?.isEmpty() == true) { b.dnsServersText.visibility = View.GONE b.dnsServersLabel.visibility = View.GONE @@ -228,93 +404,21 @@ class WgConfigDetailActivity : AppCompatActivity(R.layout.activity_wg_detail) { }*/ } - /*private suspend fun createConfigOrShowErrorLayout() { - val works = isWarpWorking() - if (works) { - fetchWarpConfigFromServer() - } else { - showConfigCreationError() - } - } - - private suspend fun showConfigCreationError() { - uiCtx { - Toast.makeText(this, getString(R.string.new_warp_error_toast), Toast.LENGTH_LONG).show() - } - }*/ - - /*private suspend fun fetchWarpConfigFromServer() { - val config = WireguardManager.getNewWarpConfig(WARP_ID) - Log.i(LOG_TAG_PROXY, "new config from server: ${config?.getName()}") - if (config == null) { - showConfigCreationError() - return - } - uiCtx { - showWarpConfig() - prefillWarpConfig(config) - } - }*/ - - /* private suspend fun handleWarpConfigView() { - if (configId == WARP_ID) { - if (isWarpConfAvailable()) { - if (DEBUG) Log.d(LOG_TAG_PROXY, "warp config already available") - showWarpConfig() - } else { - if (DEBUG) Log.d(LOG_TAG_PROXY, "warp config not found, show new config layout") - showNewWarpConfigLayout() - } - } else { - b.interfaceEdit.visibility = View.VISIBLE - b.interfaceDelete.visibility = View.VISIBLE - b.interfaceRefresh.visibility = View.GONE - b.addPeerFab.visibility = View.VISIBLE - } - } - - private suspend fun isWarpConfAvailable(): Boolean { - return WireguardManager.getWarpConfig() != null - }*/ - - /*private fun showNewWarpConfigLayout() { - b.interfaceDetailCard.visibility = View.GONE - b.peersList.visibility = View.GONE - b.addPeerFab.visibility = View.GONE - b.newConfLayout.visibility = View.VISIBLE - } - - private fun showWarpConfig() { - b.interfaceDetailCard.visibility = View.VISIBLE - b.peersList.visibility = View.VISIBLE - b.addPeerFab.visibility = View.GONE - b.interfaceEdit.visibility = View.GONE - b.interfaceDelete.visibility = View.GONE - b.interfaceRefresh.visibility = View.VISIBLE - hideNewWarpConfLayout() - } - - private fun hideNewWarpConfLayout() { - b.newConfLayout.visibility = View.GONE - b.interfaceDetailCard.visibility = View.VISIBLE - b.peersList.visibility = View.VISIBLE - } - */ private fun handleAppsCount() { - val id = ProxyManager.ID_WG_BASE + configId + val id = ID_WG_BASE + configId b.applicationsBtn.isEnabled = true mappingViewModel.getAppCountById(id).observe(this) { if (it == 0) { - b.applicationsBtn.setTextColor(UIUtils.fetchColor(this, R.attr.accentBad)) + b.applicationsBtn.setTextColor(fetchColor(this, R.attr.accentBad)) } else { - b.applicationsBtn.setTextColor(UIUtils.fetchColor(this, R.attr.accentGood)) + b.applicationsBtn.setTextColor(fetchColor(this, R.attr.accentGood)) } b.applicationsBtn.text = getString(R.string.add_remove_apps, it.toString()) } } private fun setupClickListeners() { - b.interfaceEdit.setOnClickListener { + b.editBtn.setOnClickListener { val intent = Intent(this, WgConfigEditorActivity::class.java) intent.putExtra(WgConfigEditorActivity.INTENT_EXTRA_WG_ID, configId) intent.putExtra(INTENT_EXTRA_WG_TYPE, wgType.value) @@ -328,7 +432,7 @@ class WgConfigDetailActivity : AppCompatActivity(R.layout.activity_wg_detail) { openAppsDialog(proxyName) } - b.interfaceDelete.setOnClickListener { showDeleteInterfaceDialog() } + b.deleteBtn.setOnClickListener { showDeleteInterfaceDialog() } /*b.newConfLayout.setOnClickListener { b.newConfProgressBar.visibility = View.VISIBLE @@ -356,60 +460,161 @@ class WgConfigDetailActivity : AppCompatActivity(R.layout.activity_wg_detail) { b.lockdownCheck.setOnClickListener { updateLockdown(b.lockdownCheck.isChecked) } b.catchAllCheck.setOnClickListener { updateCatchAll(b.catchAllCheck.isChecked) } + + b.useMobileCheck.setOnClickListener { updateUseOnMobileNetwork(b.useMobileCheck.isChecked) } + + b.logsBtn.setOnClickListener { + startActivity(ID_WG_BASE + configId) + } + + b.hopBtn.setOnClickListener { + val mapping = WireguardManager.getConfigFilesById(configId) + if (mapping == null) { + Utilities.showToastUiCentered(this, getString(R.string.mapping_not_available), Toast.LENGTH_SHORT) + return@setOnClickListener + } + + if (mapping.isActive || mapping.isLockdown || mapping.isCatchAll) { + io { + val sid = ID_WG_BASE + configId + val isVia = WgHopManager.isAlreadyHop(sid) + if (isVia) { + uiCtx { + Utilities.showToastUiCentered( + this, + getString(R.string.hop_error_toast_msg_1), + Toast.LENGTH_SHORT + ) + } + return@io + } + val hopId = WgHopManager.getHop(configId) + Logger.d(LOG_TAG_PROXY, "hop result: $hopId") + val iid = convertStringIdToId(hopId) + val hopables = WgHopManager.getHopableWgs(configId) + uiCtx { + if (hopables.isEmpty()) { + Utilities.showToastUiCentered( + this, + getString(R.string.hop_error_toast_msg_2), + Toast.LENGTH_SHORT + ) + } else { + openHopDialog(hopables, iid) + } + } + } + } else { + Utilities.showToastUiCentered(this, getString(R.string.wireguard_not_active_toast), Toast.LENGTH_SHORT) + } + } + } + + private fun convertStringIdToId(id: String): Int { + return try { + val configId = id.substring(ID_WG_BASE.length) + configId.toIntOrNull() ?: INVALID_CONF_ID + } catch (ignored: Exception) { + Logger.i(LOG_TAG_PROXY, "err converting string id to int: $id") + INVALID_CONF_ID + } + } + + private fun startActivity(searchParam: String?) { + val intent = Intent(this, NetworkLogsActivity::class.java) + val query = RULES_SEARCH_ID_WIREGUARD + searchParam + intent.putExtra(Constants.SEARCH_QUERY, query) + startActivity(intent) } private fun updateLockdown(enabled: Boolean) { io { WireguardManager.updateLockdownConfig(configId, enabled) } } - /* private fun updateOneWireGuard(enabled: Boolean) { - io { - WireguardManager.updateOneWireGuardConfig(configId, enabled) - uiCtx { - // disable add apps button - b.applicationsBtn.isEnabled = !enabled - b.applicationsBtn.text = getString(R.string.one_wg_apps_added) - Toast.makeText(this, getString(R.string.one_wg_success_toast), Toast.LENGTH_SHORT) - .show() - } - } - }*/ + private fun updateUseOnMobileNetwork(enabled: Boolean) { + io { WireguardManager.updateUseOnMobileNetworkConfig(configId, enabled) } + } private fun updateCatchAll(enabled: Boolean) { io { - val config = WireguardManager.getConfigFilesById(configId) - if (config == null) { - Logger.e(LOG_TAG_PROXY, "updateCatchAll: config not found for $configId") + if (!VpnController.hasTunnel()) { + uiCtx { + Utilities.showToastUiCentered( + this, + ERR_CODE_VPN_NOT_ACTIVE + getString(R.string.settings_socks5_vpn_disabled_error), + Toast.LENGTH_LONG + ) + b.catchAllCheck.isChecked = !enabled + } return@io } - if (WireguardManager.canEnableConfig(config)) { - WireguardManager.updateCatchAllConfig(configId, enabled) + + if (!WireguardManager.canEnableProxy()) { + Logger.i( + LOG_TAG_PROXY, + "not in DNS+Firewall mode, cannot enable WireGuard" + ) uiCtx { - b.lockdownCheck.isEnabled = !enabled - b.applicationsBtn.isEnabled = !enabled - if (enabled) { - b.applicationsBtn.text = getString(R.string.routing_remaining_apps) - } else { - handleAppsCount() - } + // reset the check box + b.catchAllCheck.isChecked = false + Utilities.showToastUiCentered( + this, + ERR_CODE_VPN_NOT_FULL + getString(R.string.wireguard_enabled_failure), + Toast.LENGTH_LONG + ) } - } else { + return@io + } + + if (WireguardManager.oneWireGuardEnabled()) { + // this should not happen, ui is disabled if one wireGuard is enabled + Logger.w(LOG_TAG_PROXY, "one wireGuard is already enabled") uiCtx { + // reset the check box + b.catchAllCheck.isChecked = false Utilities.showToastUiCentered( this, - getString(R.string.wireguard_enabled_failure), + ERR_CODE_OTHER_WG_ACTIVE + getString( + R.string.wireguard_enabled_failure + ), Toast.LENGTH_LONG ) + } + return@io + } + + + val config = WireguardManager.getConfigFilesById(configId) + if (config == null) { + Logger.e(LOG_TAG_PROXY, "updateCatchAll: config not found for $configId") + uiCtx { + // reset the check box b.catchAllCheck.isChecked = false + Utilities.showToastUiCentered( + this, + ERR_CODE_WG_INVALID + getString(R.string.wireguard_enabled_failure), + Toast.LENGTH_LONG + ) } return@io } + + WireguardManager.updateCatchAllConfig(configId, enabled) + uiCtx { + b.lockdownCheck.isEnabled = !enabled + b.applicationsBtn.isEnabled = !enabled + if (enabled) { + b.applicationsBtn.text = getString(R.string.routing_remaining_apps) + } else { + handleAppsCount() + } + } } } private fun openAppsDialog(proxyName: String) { val themeId = Themes.getCurrentTheme(isDarkThemeOn(), persistentState.theme) - val proxyId = ProxyManager.ID_WG_BASE + configId + val proxyId = ID_WG_BASE + configId val appsAdapter = WgIncludeAppsAdapter(this, proxyId, proxyName) mappingViewModel.apps.observe(this) { appsAdapter.submitData(lifecycle, it) } val includeAppsDialog = @@ -418,6 +623,22 @@ class WgConfigDetailActivity : AppCompatActivity(R.layout.activity_wg_detail) { includeAppsDialog.show() } + private fun openHopDialog(hopables: List, selectedId: Int) { + val curr = WireguardManager.getConfigById(configId) + if (curr == null) { + Utilities.showToastUiCentered( + this, + getString(R.string.config_invalid_desc), + Toast.LENGTH_SHORT + ) + return + } + val themeId = Themes.getCurrentTheme(isDarkThemeOn(), persistentState.theme) + val hopDialog = WgHopDialog(this, themeId, configId, hopables, selectedId) + hopDialog.setCanceledOnTouchOutside(false) + hopDialog.show() + } + private fun showDeleteInterfaceDialog() { val builder = MaterialAlertDialogBuilder(this) val delText = diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/WgConfigEditorActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/WgConfigEditorActivity.kt index e040ede3f..df5b36743 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/activity/WgConfigEditorActivity.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/activity/WgConfigEditorActivity.kt @@ -23,8 +23,8 @@ import android.content.res.Configuration import android.os.Bundle import android.widget.Toast import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.lifecycleScope -import backend.Backend import by.kirich1409.viewbindingdelegate.viewBinding import com.celzero.bravedns.R import com.celzero.bravedns.databinding.ActivityWgConfigEditorBinding @@ -34,9 +34,12 @@ import com.celzero.bravedns.ui.activity.WgConfigDetailActivity.Companion.INTENT_ import com.celzero.bravedns.util.Themes import com.celzero.bravedns.util.UIUtils.clipboardCopy import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.isAtleastQ +import com.celzero.bravedns.util.Utilities.tos import com.celzero.bravedns.wireguard.Config import com.celzero.bravedns.wireguard.WgInterface import com.celzero.bravedns.wireguard.util.ErrorMessages +import com.celzero.firestack.backend.Backend import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -54,7 +57,7 @@ class WgConfigEditorActivity : AppCompatActivity(R.layout.activity_wg_config_edi companion object { const val INTENT_EXTRA_WG_ID = "WIREGUARD_TUNNEL_ID" private const val CLIPBOARD_PUBLIC_KEY_LBL = "Public Key" - private const val DEFAULT_MTU = "1280" + private const val DEFAULT_MTU = "-1" // when dns is set to auto, the default dns is set to 1.1.1.1. this differs from official // wireguard for android, because rethink requires a dns to be set in "Simple" mode private const val DEFAULT_DNS = "1.1.1.1" @@ -64,6 +67,13 @@ class WgConfigEditorActivity : AppCompatActivity(R.layout.activity_wg_config_edi override fun onCreate(savedInstanceState: Bundle?) { setTheme(Themes.getCurrentTheme(isDarkThemeOn(), persistentState.theme)) super.onCreate(savedInstanceState) + + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + configId = intent.getIntExtra(INTENT_EXTRA_WG_ID, WireguardManager.INVALID_CONF_ID) wgType = WgConfigDetailActivity.WgType.fromInt( @@ -93,8 +103,8 @@ class WgConfigEditorActivity : AppCompatActivity(R.layout.activity_wg_config_edi uiCtx { b.interfaceNameText.setText(wgConfig?.getName()) - b.privateKeyText.setText(wgInterface?.getKeyPair()?.getPrivateKey()?.base64()) - b.publicKeyText.setText(wgInterface?.getKeyPair()?.getPublicKey()?.base64()) + b.privateKeyText.setText(wgInterface?.getKeyPair()?.getPrivateKey()?.base64().tos()) + b.publicKeyText.setText(wgInterface?.getKeyPair()?.getPublicKey()?.base64().tos()) var dns = wgInterface?.dnsServers?.joinToString { it.hostAddress?.toString() ?: "" } val searchDomains = wgInterface?.dnsSearchDomains?.joinToString { it } dns = @@ -109,19 +119,28 @@ class WgConfigEditorActivity : AppCompatActivity(R.layout.activity_wg_config_edi wgInterface?.getAddresses()?.joinToString { it.toString() } ) } - if ( - wgInterface?.listenPort?.isPresent == true && - wgInterface?.listenPort?.get() != 1 && wgType.isOneWg() - ) { + if (showListenPort()) { b.listenPortText.setText(wgInterface?.listenPort?.get().toString()) } if (wgInterface?.mtu?.isPresent == true) { b.mtuText.setText(wgInterface?.mtu?.get().toString()) } + if (wgInterface?.isAmnezia() == true) { + b.amzProps.visibility = android.view.View.VISIBLE + b.amzProps.text = wgInterface?.getAmzProps() + } else { + b.amzProps.visibility = android.view.View.GONE + } } } } + private fun showListenPort(): Boolean { + val isPresent = wgInterface?.listenPort?.isPresent == true && wgInterface?.listenPort?.get() != 1 + val byType = wgType.isOneWg() || (!persistentState.randomizeListenPort && wgType.isDefault()) + return isPresent && byType + } + private fun setupClickListeners() { b.privateKeyTextLayout.setEndIconOnClickListener { val key = Backend.newWgPrivateKey() diff --git a/app/src/full/java/com/celzero/bravedns/ui/activity/WgMainActivity.kt b/app/src/full/java/com/celzero/bravedns/ui/activity/WgMainActivity.kt index ff2f72d76..821767e66 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/activity/WgMainActivity.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/activity/WgMainActivity.kt @@ -24,9 +24,11 @@ import android.content.res.Configuration import android.os.Bundle import android.view.View import android.widget.Toast -import androidx.activity.addCallback +import androidx.activity.OnBackPressedCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowInsetsControllerCompat +import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import by.kirich1409.viewbindingdelegate.viewBinding @@ -43,6 +45,7 @@ import com.celzero.bravedns.util.TunnelImporter import com.celzero.bravedns.util.UIUtils import com.celzero.bravedns.util.UIUtils.fetchToggleBtnColors import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.isAtleastQ import com.celzero.bravedns.viewmodel.WgConfigViewModel import com.google.android.material.button.MaterialButton import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -152,6 +155,26 @@ class WgMainActivity : setTheme(Themes.getCurrentTheme(isDarkThemeOn(), persistentState.theme)) super.onCreate(savedInstanceState) init() + + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + + onBackPressedDispatcher.addCallback( + this /* lifecycle owner */, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (b.createFab.isVisible) { + collapseFab() + } else { + finish() + } + return + } + } + ) } private fun init() { @@ -160,14 +183,6 @@ class WgMainActivity : observeConfig() observeDnsName() setupClickListeners() - - onBackPressedDispatcher.addCallback(this /* lifecycle owner */) { - if (b.createFab.visibility == View.VISIBLE) { - collapseFab() - } else { - finish() - } - } } private fun setAdapter() { @@ -207,7 +222,7 @@ class WgMainActivity : val layoutManager = LinearLayoutManager(this) b.wgGeneralInterfaceList.layoutManager = layoutManager - wgConfigAdapter = WgConfigAdapter(this) + wgConfigAdapter = WgConfigAdapter(this, this, persistentState.splitDns) wgConfigViewModel.interfaces.observe(this) { wgConfigAdapter?.submitData(lifecycle, it) } b.wgGeneralInterfaceList.adapter = wgConfigAdapter } @@ -277,18 +292,32 @@ class WgMainActivity : } private fun observeDnsName() { + val activeConfigs = WireguardManager.getActiveConfigs() if (WireguardManager.oneWireGuardEnabled()) { - val activeConfigs = WireguardManager.getEnabledConfigs() - val isAnyConfigActive = activeConfigs.isNotEmpty() - if (isAnyConfigActive) { - val dnsName = activeConfigs.firstOrNull()?.getName() ?: return + val dnsName = activeConfigs.firstOrNull()?.getName() ?: return + if (isAtleastQ()) { b.wgWireguardDisclaimer.text = getString(R.string.wireguard_disclaimer, dnsName) + } else { + b.wgWireguardDisclaimer.text = getString(R.string.wireguard_disclaimer_below_android_Q) } // remove the observer if any config is active appConfig.getConnectedDnsObservable().removeObservers(this) } else { - appConfig.getConnectedDnsObservable().observe(this) { - b.wgWireguardDisclaimer.text = getString(R.string.wireguard_disclaimer, it) + appConfig.getConnectedDnsObservable().observe(this) { dns -> + var dnsNames: String = dns.ifEmpty { "" } + if (persistentState.splitDns) { + if (activeConfigs.isNotEmpty()) { + dnsNames += "," + } + dnsNames += activeConfigs.joinToString(",") { it.getName() } + } + if (isAtleastQ()) { + b.wgWireguardDisclaimer.text = + getString(R.string.wireguard_disclaimer, dnsNames) + } else { + b.wgWireguardDisclaimer.text = + getString(R.string.wireguard_disclaimer_below_android_Q) + } } } } @@ -297,7 +326,7 @@ class WgMainActivity : // see CustomIpFragment#setupClickListeners#bringToFront() b.wgAddFab.bringToFront() b.wgAddFab.setOnClickListener { - if (b.createFab.visibility == View.VISIBLE) { + if (b.createFab.isVisible) { collapseFab() } else { expendFab() @@ -324,7 +353,7 @@ class WgMainActivity : showGeneralToggle() } b.oneWgToggleBtn.setOnClickListener { - val activeConfigs = WireguardManager.getEnabledConfigs() + val activeConfigs = WireguardManager.getActiveConfigs() val isAnyConfigActive = activeConfigs.isNotEmpty() val isOneWgEnabled = WireguardManager.oneWireGuardEnabled() if (isAnyConfigActive && !isOneWgEnabled) { diff --git a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/AppDomainRulesBottomSheet.kt b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/AppDomainRulesBottomSheet.kt index b5c6ff642..080b602a2 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/AppDomainRulesBottomSheet.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/AppDomainRulesBottomSheet.kt @@ -17,31 +17,38 @@ package com.celzero.bravedns.ui.bottomsheet import Logger import Logger.LOG_TAG_FIREWALL +import Logger.LOG_TAG_UI import android.content.DialogInterface import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.Toast import androidx.core.content.ContextCompat +import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.lifecycleScope import com.celzero.bravedns.R import com.celzero.bravedns.adapter.AppWiseDomainsAdapter +import com.celzero.bravedns.database.CustomDomain +import com.celzero.bravedns.database.WgConfigFilesImmutable import com.celzero.bravedns.databinding.BottomSheetAppConnectionsBinding import com.celzero.bravedns.service.DomainRulesManager import com.celzero.bravedns.service.FirewallManager import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.service.WireguardManager import com.celzero.bravedns.util.Constants.Companion.INVALID_UID import com.celzero.bravedns.util.Themes.Companion.getBottomsheetCurrentTheme import com.celzero.bravedns.util.UIUtils.updateHtmlEncodedText import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.isAtleastQ import com.google.android.material.bottomsheet.BottomSheetDialogFragment import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.android.ext.android.inject -class AppDomainRulesBottomSheet : BottomSheetDialogFragment() { +class AppDomainRulesBottomSheet : BottomSheetDialogFragment(), WireguardListBtmSheet.WireguardDismissListener { private var _binding: BottomSheetAppConnectionsBinding? = null // This property is only valid between onCreateView and onDestroyView. @@ -61,10 +68,12 @@ class AppDomainRulesBottomSheet : BottomSheetDialogFragment() { private var uid: Int = -1 private var domain: String = "" private var domainRule: DomainRulesManager.Status = DomainRulesManager.Status.NONE + private var cd: CustomDomain? = null companion object { const val UID = "UID" const val DOMAIN = "DOMAIN" + private const val TAG = "AppDomainBtmSht" } private fun isDarkThemeOn(): Boolean { @@ -97,6 +106,14 @@ class AppDomainRulesBottomSheet : BottomSheetDialogFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + dialog?.window?.let { window -> + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + } + uid = arguments?.getInt(UID) ?: INVALID_UID domain = arguments?.getString(DOMAIN) ?: "" @@ -112,7 +129,12 @@ class AppDomainRulesBottomSheet : BottomSheetDialogFragment() { this.dismiss() return } - + io { + cd = DomainRulesManager.getObj(uid, domain) + if (cd == null) { + cd = DomainRulesManager.makeCustomDomain(uid, domain) + } + } updateAppDetails() // making use of the same layout used for ip rules, so changing the text and // removing the recycler related changes @@ -169,7 +191,7 @@ class AppDomainRulesBottomSheet : BottomSheetDialogFragment() { io { // no need to send port number for the app info screen domainRule = DomainRulesManager.status(domain, uid) - Logger.d(LOG_TAG_FIREWALL, "Set selection of ip: $domain, ${domainRule.id}") + Logger.d(LOG_TAG_FIREWALL, "$TAG set selection of ip: $domain, ${domainRule.id}") uiCtx { when (domainRule) { DomainRulesManager.Status.TRUST -> { @@ -215,10 +237,45 @@ class AppDomainRulesBottomSheet : BottomSheetDialogFragment() { enableTrustUi() } } + + b.chooseProxyRl.setOnClickListener { + val ctx = requireContext() + var v: MutableList = mutableListOf() + io { + v.add(null) + v.addAll(WireguardManager.getAllMappings()) + if (v.isEmpty()) { + Logger.v(LOG_TAG_UI, "$TAG no wireguard configs found") + uiCtx { + Utilities.showToastUiCentered( + ctx, + getString(R.string.wireguard_no_config_msg), + Toast.LENGTH_SHORT + ) + } + return@io + } + uiCtx { + Logger.v(LOG_TAG_UI, "$TAG show wg list(${v.size} for ${cd?.domain}, $uid") + showWgListBtmSheet(v) + } + } + } + + } + + + private fun showWgListBtmSheet(data: List) { + Logger.v(LOG_TAG_UI, "$TAG show wg list(${data.size} for ${cd?.domain}, uid: $uid") + val bottomSheetFragment = WireguardListBtmSheet.newInstance(WireguardListBtmSheet.InputType.DOMAIN, cd, data, this) + bottomSheetFragment.show( + requireActivity().supportFragmentManager, + bottomSheetFragment.tag + ) } private fun applyDomainRule(status: DomainRulesManager.Status) { - Logger.i(LOG_TAG_FIREWALL, "domain rule for uid: $uid:$domain (${status.name})") + Logger.i(LOG_TAG_FIREWALL, "$TAG domain rule for uid: $uid:$domain (${status.name})") domainRule = status // set port number as null for all the rules applied from this screen @@ -272,4 +329,15 @@ class AppDomainRulesBottomSheet : BottomSheetDialogFragment() { private suspend fun uiCtx(f: suspend () -> Unit) { withContext(Dispatchers.Main) { f() } } + + override fun onDismissWg(obj: Any?) { + try { + val customDomain = obj as CustomDomain + cd = customDomain + setRulesUi() + Logger.i(LOG_TAG_UI, "$TAG onDismissWg: ${cd?.domain}, $uid, ${cd?.proxyId}") + } catch (e: Exception) { + Logger.e(LOG_TAG_UI, "$TAG err in onDismissWg ${e.message}", e) + } + } } diff --git a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/AppIpRulesBottomSheet.kt b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/AppIpRulesBottomSheet.kt index 781a530e8..bcabb92ca 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/AppIpRulesBottomSheet.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/AppIpRulesBottomSheet.kt @@ -17,33 +17,40 @@ package com.celzero.bravedns.ui.bottomsheet import Logger import Logger.LOG_TAG_FIREWALL +import Logger.LOG_TAG_UI import android.content.DialogInterface import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.Toast import androidx.core.content.ContextCompat +import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.lifecycleScope import com.celzero.bravedns.R import com.celzero.bravedns.adapter.AppWiseIpsAdapter import com.celzero.bravedns.adapter.DomainRulesBtmSheetAdapter +import com.celzero.bravedns.database.CustomIp +import com.celzero.bravedns.database.WgConfigFilesImmutable import com.celzero.bravedns.databinding.BottomSheetAppConnectionsBinding import com.celzero.bravedns.service.FirewallManager import com.celzero.bravedns.service.IpRulesManager import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.service.WireguardManager import com.celzero.bravedns.util.Constants.Companion.INVALID_UID import com.celzero.bravedns.util.CustomLinearLayoutManager import com.celzero.bravedns.util.Themes.Companion.getBottomsheetCurrentTheme import com.celzero.bravedns.util.UIUtils.updateHtmlEncodedText import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.isAtleastQ import com.google.android.material.bottomsheet.BottomSheetDialogFragment import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.android.ext.android.inject -class AppIpRulesBottomSheet : BottomSheetDialogFragment() { +class AppIpRulesBottomSheet : BottomSheetDialogFragment(), WireguardListBtmSheet.WireguardDismissListener { private var _binding: BottomSheetAppConnectionsBinding? = null // This property is only valid between onCreateView and onDestroyView. @@ -57,6 +64,8 @@ class AppIpRulesBottomSheet : BottomSheetDialogFragment() { private var adapter: AppWiseIpsAdapter? = null private var position: Int = -1 + private var ci: CustomIp? = null + override fun getTheme(): Int = getBottomsheetCurrentTheme(isDarkThemeOn(), persistentState.theme) @@ -69,6 +78,7 @@ class AppIpRulesBottomSheet : BottomSheetDialogFragment() { const val UID = "UID" const val IP_ADDRESS = "IP_ADDRESS" const val DOMAINS = "DOMAINS" + private const val TAG = "AppIpRulesBtmSht" } private fun isDarkThemeOn(): Boolean { @@ -101,6 +111,14 @@ class AppIpRulesBottomSheet : BottomSheetDialogFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + + dialog?.window?.let { window -> + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + } uid = arguments?.getInt(UID) ?: INVALID_UID ipAddress = arguments?.getString(IP_ADDRESS) ?: "" domains = arguments?.getString(DOMAINS) ?: "" @@ -118,6 +136,13 @@ class AppIpRulesBottomSheet : BottomSheetDialogFragment() { return } + io { + ci = IpRulesManager.getObj(uid, ipAddress) + if (ci == null) { + ci = IpRulesManager.mkCustomIp(uid, ipAddress) + } + } + updateAppDetails() b.bsacIpAddressTv.text = ipAddress @@ -187,7 +212,7 @@ class AppIpRulesBottomSheet : BottomSheetDialogFragment() { io { // no need to send port number for the app info screen ipRule = IpRulesManager.getMostSpecificRuleMatch(uid, ipAddress) - Logger.d(LOG_TAG_FIREWALL, "Set selection of ip: $ipAddress, ${ipRule.id}") + Logger.d(LOG_TAG_FIREWALL, "$TAG set selection of ip: $ipAddress, ${ipRule.id}") uiCtx { when (ipRule) { IpRulesManager.IpRuleStatus.TRUST -> { @@ -236,16 +261,49 @@ class AppIpRulesBottomSheet : BottomSheetDialogFragment() { enableTrustUi() } } + + b.chooseProxyRl.setOnClickListener { + val ctx = requireContext() + var v: MutableList = mutableListOf() + io { + v.add(null) + v.addAll(WireguardManager.getAllMappings()) + if (v.isEmpty() || v.size == 1) { + Logger.w(LOG_TAG_UI, "$TAG No Wireguard configs found") + uiCtx { + Utilities.showToastUiCentered( + ctx, + getString(R.string.wireguard_no_config_msg), + Toast.LENGTH_SHORT + ) + } + return@io + } + uiCtx { + Logger.v(LOG_TAG_UI, "$TAG show wg list(${v.size}) for ${ci?.ipAddress ?: ""}") + showWgListBtmSheet(v) + } + } + } + } + + private fun showWgListBtmSheet(data: List) { + val bottomSheetFragment = + WireguardListBtmSheet.newInstance(WireguardListBtmSheet.InputType.IP, ci, data, this) + bottomSheetFragment.show( + requireActivity().supportFragmentManager, + bottomSheetFragment.tag + ) } private fun applyIpRule(status: IpRulesManager.IpRuleStatus) { - Logger.i(LOG_TAG_FIREWALL, "ip rule for uid: $uid, ip: $ipAddress (${status.name})") + Logger.i(LOG_TAG_FIREWALL, "$TAG ip rule for uid: $uid, ip: $ipAddress (${status.name})") ipRule = status val ipPair = IpRulesManager.getIpNetPort(ipAddress) val ip = ipPair.first ?: return // set port number as null for all the rules applied from this screen - io { IpRulesManager.addIpRule(uid, ip, null, status) } + io { IpRulesManager.addIpRule(uid, ip, null, status, proxyId = "", proxyCC = "") } } override fun onDismiss(dialog: DialogInterface) { @@ -287,4 +345,15 @@ class AppIpRulesBottomSheet : BottomSheetDialogFragment() { private suspend fun uiCtx(f: suspend () -> Unit) { withContext(Dispatchers.Main) { f() } } + + override fun onDismissWg(obj: Any?) { + try { + val cip = obj as CustomIp + ci = cip + setRulesUi() + Logger.v(LOG_TAG_UI, "$TAG: onDismissWg: ${cip.ipAddress}, ${cip.proxyCC}") + } catch (e: Exception) { + Logger.w(LOG_TAG_UI, "$TAG: err in onDismissWg ${e.message}", e) + } + } } diff --git a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/BackupRestoreBottomSheet.kt b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/BackupRestoreBottomSheet.kt index 525f14ec2..39cdf681d 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/BackupRestoreBottomSheet.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/BackupRestoreBottomSheet.kt @@ -32,6 +32,7 @@ import android.widget.Toast import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.lifecycleScope import androidx.work.BackoffPolicy import androidx.work.Data @@ -56,6 +57,7 @@ import com.celzero.bravedns.service.PersistentState import com.celzero.bravedns.util.Themes import com.celzero.bravedns.util.Utilities import com.celzero.bravedns.util.Utilities.delay +import com.celzero.bravedns.util.Utilities.isAtleastQ import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.dialog.MaterialAlertDialogBuilder import java.text.SimpleDateFormat @@ -100,6 +102,13 @@ class BackupRestoreBottomSheet : BottomSheetDialogFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + dialog?.window?.let { window -> + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + } result() init() } @@ -160,18 +169,19 @@ class BackupRestoreBottomSheet : BottomSheetDialogFragment() { LOG_TAG_BACKUP_RESTORE, "WorkManager state: ${workInfo.state} for ${BackupAgent.TAG}" ) - if (workInfo.state == WorkInfo.State.SUCCEEDED) { - showBackupSuccessUi() - workManager.pruneWork() - } else if ( - workInfo.state == WorkInfo.State.CANCELLED || - workInfo.state == WorkInfo.State.FAILED - ) { - showBackupFailureDialog() - workManager.pruneWork() - workManager.cancelAllWorkByTag(BackupAgent.TAG) - } else { - // no-op + when (workInfo.state) { + WorkInfo.State.SUCCEEDED -> { + showBackupSuccessUi() + workManager.pruneWork() + } + WorkInfo.State.CANCELLED, WorkInfo.State.FAILED -> { + showBackupFailureDialog() + workManager.pruneWork() + workManager.cancelAllWorkByTag(BackupAgent.TAG) + } + else -> { + // no-op + } } } } diff --git a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/ConnTrackerBottomSheet.kt b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/ConnTrackerBottomSheet.kt index 4c55004e4..7ce4d43c0 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/ConnTrackerBottomSheet.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/ConnTrackerBottomSheet.kt @@ -34,6 +34,7 @@ import android.widget.AdapterView import android.widget.Toast import androidx.appcompat.widget.AppCompatImageView import androidx.core.content.ContextCompat +import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.lifecycleScope import com.celzero.bravedns.R import com.celzero.bravedns.adapter.FirewallStatusSpinnerAdapter @@ -47,6 +48,7 @@ import com.celzero.bravedns.service.FirewallRuleset import com.celzero.bravedns.service.FirewallRuleset.Companion.getFirewallRule import com.celzero.bravedns.service.IpRulesManager import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.service.ProxyManager.isIpnProxy import com.celzero.bravedns.service.VpnController import com.celzero.bravedns.ui.activity.AppInfoActivity import com.celzero.bravedns.util.Constants @@ -57,6 +59,7 @@ import com.celzero.bravedns.util.UIUtils.fetchColor import com.celzero.bravedns.util.UIUtils.updateHtmlEncodedText import com.celzero.bravedns.util.Utilities import com.celzero.bravedns.util.Utilities.getIcon +import com.celzero.bravedns.util.Utilities.isAtleastQ import com.celzero.bravedns.util.Utilities.showToastUiCentered import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -105,6 +108,15 @@ class ConnTrackerBottomSheet : BottomSheetDialogFragment(), KoinComponent { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + + dialog?.window?.let { window -> + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + } + val data = arguments?.getString(INSTANCE_STATE_IPDETAILS) info = Gson().fromJson(data, ConnectionTracker::class.java) initView() @@ -154,7 +166,7 @@ class ConnTrackerBottomSheet : BottomSheetDialogFragment(), KoinComponent { return } // updates the ip rules button - updateIpRulesUi(info!!.uid, info!!.ipAddress, info!!.port) + updateIpRulesUi(info!!.uid, info!!.ipAddress) // updates the value from dns request cache if available updateDnsIfAvailable() } @@ -226,13 +238,24 @@ class ConnTrackerBottomSheet : BottomSheetDialogFragment(), KoinComponent { } val rule = info!!.blockedByRule + val isIpnProxy = isIpnProxy(info?.proxyDetails ?: "") // TODO: below code is not required, remove it in future (20/03/2023) if (rule.contains(FirewallRuleset.RULE2G.id)) { b.bsConnTrackAppInfo.text = getFirewallRule(FirewallRuleset.RULE2G.id)?.title?.let { getString(it) } return + } else if (!info?.proxyDetails.isNullOrEmpty() && isIpnProxy) { + // add the proxy id to the chip text if available + b.bsConnTrackAppInfo.text = getString(R.string.two_argument_colon, getFirewallRule(rule)?.title?.let { getString(it) }, info?.proxyDetails) } else { - b.bsConnTrackAppInfo.text = getFirewallRule(rule)?.title?.let { getString(it) } + val isRuleAddedAsProxy = getFirewallRule(rule)?.id == FirewallRuleset.RULE12.id + // when the conn is marked as proxied with id from flow, but the returned summary + // doesn't have the proxy details. change the rule from proxied to none + if (isRuleAddedAsProxy && (info?.proxyDetails.isNullOrEmpty() || !isIpnProxy)) { + b.bsConnTrackAppInfo.text = getString(getFirewallRule(FirewallRuleset.RULE0.id)?.title ?: R.string.firewall_rule_no_rule) + } else { + b.bsConnTrackAppInfo.text = getFirewallRule(rule)?.title?.let { getString(it) } + } } } @@ -276,10 +299,16 @@ class ConnTrackerBottomSheet : BottomSheetDialogFragment(), KoinComponent { private fun displaySummaryDetails() { b.bsConnConnTypeSecondary.visibility = View.GONE - b.connectionMessage.text = info?.message + // show connId and message if the log level is less than DEBUG + if (Logger.LoggerLevel.fromId(persistentState.goLoggerLevel.toInt()) + .isLessThan(Logger.LoggerLevel.DEBUG) + ) { + b.connectionMessage.text = "${info?.proxyDetails}; ${info?.rpid}; ${info?.connId}; ${info?.message}; ${info?.synack}" + } else { + b.connectionMessage.text = info?.message + } if (VpnController.hasCid(info!!.connId, info!!.uid)) { - b.connectionMessageLl.visibility = View.VISIBLE b.bsConnConnDuration.text = getString( R.string.two_argument_space, @@ -318,7 +347,6 @@ class ConnTrackerBottomSheet : BottomSheetDialogFragment(), KoinComponent { info?.downloadBytes == 0L && info?.uploadBytes == 0L ) { - b.connectionMessageLl.visibility = View.GONE b.bsConnSummaryDetailLl.visibility = View.GONE b.bsConnConnTypeSecondary.visibility = View.VISIBLE b.bsConnConnTypeSecondary.text = b.bsConnConnType.text @@ -452,6 +480,20 @@ class ConnTrackerBottomSheet : BottomSheetDialogFragment(), KoinComponent { return@io } + // TODO: instead disable/remove exclude from the view if pkg is unknown? + if (FirewallManager.isUnknownPackage(info!!.uid) && fStatus.isExclude()) { + uiCtx { + // reset the spinner to previous selection + updateFirewallRulesUi(a, c) + showToastUiCentered( + requireContext(), + requireContext().getString(R.string.exclude_no_package_err_toast), + Toast.LENGTH_LONG + ) + } + return@io + } + Logger.i( LOG_TAG_FIREWALL, "Change in firewall rule for app uid: ${info?.uid}, firewall status: $fStatus, conn status: $connStatus" @@ -525,7 +567,7 @@ class ConnTrackerBottomSheet : BottomSheetDialogFragment(), KoinComponent { private fun openAppDetailActivity(uid: Int) { this.dismiss() val intent = Intent(requireContext(), AppInfoActivity::class.java) - intent.putExtra(AppInfoActivity.UID_INTENT_NAME, uid) + intent.putExtra(AppInfoActivity.INTENT_UID, uid) requireContext().startActivity(intent) } @@ -571,7 +613,7 @@ class ConnTrackerBottomSheet : BottomSheetDialogFragment(), KoinComponent { } } - private fun updateIpRulesUi(uid: Int, ipAddress: String, port: Int) { + private fun updateIpRulesUi(uid: Int, ipAddress: String) { io { val rule = IpRulesManager.getMostSpecificRuleMatch(uid, ipAddress) uiCtx { b.bsConnIpRuleSpinner.setSelection(rule.id) } @@ -701,7 +743,7 @@ class ConnTrackerBottomSheet : BottomSheetDialogFragment(), KoinComponent { val ipPair = IpRulesManager.getIpNetPort(info!!.ipAddress) val ip = ipPair.first ?: return@io - IpRulesManager.addIpRule(info!!.uid, ip, /*wildcard-port*/ 0, ipRuleStatus) + IpRulesManager.addIpRule(info!!.uid, ip, /*wildcard-port*/ 0, ipRuleStatus, proxyId = "", proxyCC = "") } } diff --git a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/CustomDomainRulesBtmSheet.kt b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/CustomDomainRulesBtmSheet.kt new file mode 100644 index 000000000..69c8e19c2 --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/CustomDomainRulesBtmSheet.kt @@ -0,0 +1,434 @@ +package com.celzero.bravedns.ui.bottomsheet + +import Logger +import Logger.LOG_TAG_UI +import android.content.DialogInterface +import android.content.res.ColorStateList +import android.content.res.Configuration +import android.os.Bundle +import android.text.format.DateUtils +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import com.celzero.bravedns.R +import com.celzero.bravedns.database.CustomDomain +import com.celzero.bravedns.database.WgConfigFilesImmutable +import com.celzero.bravedns.databinding.BottomSheetCustomDomainsBinding +import com.celzero.bravedns.rpnproxy.RegionalWgConf +import com.celzero.bravedns.rpnproxy.RpnProxyManager +import com.celzero.bravedns.service.DomainRulesManager +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.service.WireguardManager +import com.celzero.bravedns.util.Themes.Companion.getBottomsheetCurrentTheme +import com.celzero.bravedns.util.UIUtils.fetchColor +import com.celzero.bravedns.util.UIUtils.fetchToggleBtnColors +import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.isAtleastQ +import com.celzero.bravedns.wireguard.Config +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.button.MaterialButton +import com.google.android.material.button.MaterialButtonToggleGroup +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koin.android.ext.android.inject + +class CustomDomainRulesBtmSheet(private var cd: CustomDomain) : + BottomSheetDialogFragment(), ProxyCountriesBtmSheet.CountriesDismissListener, WireguardListBtmSheet.WireguardDismissListener { + private var _binding: BottomSheetCustomDomainsBinding? = null + + // This property is only valid between onCreateView and onDestroyView. + private val b + get() = _binding!! + + private val persistentState by inject() + + override fun getTheme(): Int = + getBottomsheetCurrentTheme(isDarkThemeOn(), persistentState.theme) + + private fun isDarkThemeOn(): Boolean { + return resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == + Configuration.UI_MODE_NIGHT_YES + } + + companion object { + private const val TAG = "CDRBtmSht" + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = BottomSheetCustomDomainsBinding.inflate(inflater, container, false) + return b.root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + dialog?.window?.let { window -> + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + } + + Logger.v(LOG_TAG_UI, "$TAG, view created for ${cd.domain}") + init() + initClickListeners() + + b.chooseProxyCard.setOnClickListener { + val ctx = requireContext() + var v: MutableList = mutableListOf() + io { + v.add(null) + v.addAll(WireguardManager.getAllMappings()) + if (v.isEmpty()) { + Logger.v(LOG_TAG_UI, "$TAG no wireguard configs found") + uiCtx { + Utilities.showToastUiCentered( + ctx, + getString(R.string.wireguard_no_config_msg), + Toast.LENGTH_SHORT + ) + } + return@io + } + uiCtx { + Logger.v(LOG_TAG_UI, "$TAG show wg list(${v.size} for ${cd.domain}") + showWgListBtmSheet(v) + } + } + } + + /*b.chooseCountryCard.setOnClickListener { + io { + val ctrys = RpnProxyManager.getProtonUniqueCC() + if (ctrys.isEmpty()) { + Logger.v(LOG_TAG_UI, "$TAG no country codes found") + uiCtx { + Utilities.showToastUiCentered( + requireContext(), + "No ProtonVPN country codes found", + Toast.LENGTH_SHORT + ) + } + return@io + } + uiCtx { + Logger.v(LOG_TAG_UI, "$TAG show countries(${ctrys.size} for ${cd.domain}") + showProxyCountriesBtmSheet(ctrys) + } + } + }*/ + } + + private fun init() { + val uid = cd.uid + Logger.v(LOG_TAG_UI, "$TAG, init for ${cd.domain}, uid: $uid") + val rules = DomainRulesManager.getDomainRule(cd.domain, uid) + b.customDomainTv.text = cd.domain + updateStatusUi( + DomainRulesManager.Status.getStatus(cd.status), + cd.modifiedTs + ) + b.customDomainToggleGroup.tag = 1 + updateToggleGroup(rules.id) + } + + private fun updateStatusUi(status: DomainRulesManager.Status, modifiedTs: Long) { + val now = System.currentTimeMillis() + val uptime = System.currentTimeMillis() - modifiedTs + // returns a string describing 'time' as a time relative to 'now' + val time = + DateUtils.getRelativeTimeSpanString( + now - uptime, + now, + DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE + ) + when (status) { + DomainRulesManager.Status.TRUST -> { + b.customDomainLastUpdated.text = + requireContext().getString( + R.string.ci_desc, + requireContext().getString(R.string.ci_trust_txt), + time + ) + } + + DomainRulesManager.Status.BLOCK -> { + b.customDomainLastUpdated.text = + requireContext().getString( + R.string.ci_desc, + requireContext().getString(R.string.lbl_blocked), + time + ) + } + + DomainRulesManager.Status.NONE -> { + b.customDomainLastUpdated.text = + requireContext().getString( + R.string.ci_desc, + requireContext().getString(R.string.cd_no_rule_txt), + time + ) + } + } + } + + private fun initClickListeners() { + b.customDomainToggleGroup.addOnButtonCheckedListener(domainRulesGroupListener) + + b.customDomainDeleteChip.setOnClickListener { + showDialogForDelete() + } + } + + private val domainRulesGroupListener = + MaterialButtonToggleGroup.OnButtonCheckedListener { group, checkedId, isChecked -> + val b: MaterialButton = b.customDomainToggleGroup.findViewById(checkedId) + + val statusId = findSelectedRuleByTag(getTag(b.tag)) + + // invalid selection + if (statusId == null) { + return@OnButtonCheckedListener + } + + if (isChecked) { + // See CustomIpAdapter.kt for the same code (ipRulesGroupListener) + val hasStatusChanged = cd.status != statusId.id + if (!hasStatusChanged) { + return@OnButtonCheckedListener + } + val t = toggleBtnUi(statusId) + // update toggle button + selectToggleBtnUi(b, t) + // change status based on selected btn + changeDomainStatus(statusId, cd) + } else { + unselectToggleBtnUi(b) + } + } + + private fun selectToggleBtnUi(b: MaterialButton, toggleBtnUi: ToggleBtnUi) { + b.setTextColor(toggleBtnUi.txtColor) + b.backgroundTintList = ColorStateList.valueOf(toggleBtnUi.bgColor) + } + + private fun changeDomainStatus(id: DomainRulesManager.Status, cd: CustomDomain) { + io { + when (id) { + DomainRulesManager.Status.NONE -> { + noRule(cd) + } + + DomainRulesManager.Status.BLOCK -> { + block(cd) + } + + DomainRulesManager.Status.TRUST -> { + whitelist(cd) + } + } + } + } + + private suspend fun whitelist(cd: CustomDomain) { + DomainRulesManager.trust(cd) + } + + private suspend fun block(cd: CustomDomain) { + DomainRulesManager.block(cd) + } + + private suspend fun noRule(cd: CustomDomain) { + DomainRulesManager.noRule(cd) + } + + private fun unselectToggleBtnUi(b: MaterialButton) { + b.setTextColor(fetchToggleBtnColors(requireContext(), R.color.defaultToggleBtnTxt)) + b.backgroundTintList = + ColorStateList.valueOf( + fetchToggleBtnColors( + requireContext(), + R.color.defaultToggleBtnBg + ) + ) + } + + data class ToggleBtnUi(val txtColor: Int, val bgColor: Int) + + private fun showDialogForDelete() { + val builder = MaterialAlertDialogBuilder(requireContext()) + builder.setTitle(R.string.cd_remove_dialog_title) + builder.setMessage(R.string.cd_remove_dialog_message) + builder.setCancelable(true) + builder.setPositiveButton(requireContext().getString(R.string.lbl_delete)) { _, _ -> + io { DomainRulesManager.deleteDomain(cd) } + Utilities.showToastUiCentered( + requireContext(), + requireContext().getString(R.string.cd_toast_deleted), + Toast.LENGTH_SHORT + ) + dismiss() + } + + builder.setNegativeButton(requireContext().getString(R.string.lbl_cancel)) { _, _ -> + // no-op + } + builder.create().show() + } + + private fun findSelectedRuleByTag(ruleId: Int): DomainRulesManager.Status? { + return when (ruleId) { + DomainRulesManager.Status.NONE.id -> { + DomainRulesManager.Status.NONE + } + + DomainRulesManager.Status.TRUST.id -> { + DomainRulesManager.Status.TRUST + } + + DomainRulesManager.Status.BLOCK.id -> { + DomainRulesManager.Status.BLOCK + } + + else -> { + null + } + } + } + + private fun toggleBtnUi(id: DomainRulesManager.Status): ToggleBtnUi { + return when (id) { + DomainRulesManager.Status.NONE -> { + ToggleBtnUi( + fetchColor(requireContext(), R.attr.chipTextNeutral), + fetchColor(requireContext(), R.attr.chipBgColorNeutral) + ) + } + + DomainRulesManager.Status.BLOCK -> { + ToggleBtnUi( + fetchColor(requireContext(), R.attr.chipTextNegative), + fetchColor(requireContext(), R.attr.chipBgColorNegative) + ) + } + + DomainRulesManager.Status.TRUST -> { + ToggleBtnUi( + fetchColor(requireContext(), R.attr.chipTextPositive), + fetchColor(requireContext(), R.attr.chipBgColorPositive) + ) + } + } + } + + private fun updateToggleGroup(id: Int) { + val fid = findSelectedRuleByTag(id) ?: return + + val t = toggleBtnUi(fid) + + when (id) { + DomainRulesManager.Status.NONE.id -> { + b.customDomainToggleGroup.check(b.customDomainTgNoRule.id) + selectToggleBtnUi(b.customDomainTgNoRule, t) + unselectToggleBtnUi(b.customDomainTgBlock) + unselectToggleBtnUi(b.customDomainTgWhitelist) + } + + DomainRulesManager.Status.BLOCK.id -> { + b.customDomainToggleGroup.check(b.customDomainTgBlock.id) + selectToggleBtnUi(b.customDomainTgBlock, t) + unselectToggleBtnUi(b.customDomainTgNoRule) + unselectToggleBtnUi(b.customDomainTgWhitelist) + } + + DomainRulesManager.Status.TRUST.id -> { + b.customDomainToggleGroup.check(b.customDomainTgWhitelist.id) + selectToggleBtnUi(b.customDomainTgWhitelist, t) + unselectToggleBtnUi(b.customDomainTgBlock) + unselectToggleBtnUi(b.customDomainTgNoRule) + } + } + } + + // each button in the toggle group is associated with tag value. + // tag values are ids of DomainRulesManager.DomainStatus + private fun getTag(tag: Any): Int { + return tag.toString().toIntOrNull() ?: 0 + } + + private fun showWgListBtmSheet(data: List) { + Logger.v(LOG_TAG_UI, "$TAG show wg list(${data.size} for ${cd.domain}") + val bottomSheetFragment = WireguardListBtmSheet.newInstance(WireguardListBtmSheet.InputType.DOMAIN, cd, data, this) + bottomSheetFragment.show( + requireActivity().supportFragmentManager, + bottomSheetFragment.tag + ) + } + + private fun showProxyCountriesBtmSheet(data: List) { + Logger.v(LOG_TAG_UI, "$TAG show countries(${data.size} for ${cd.domain}") + val bottomSheetFragment = ProxyCountriesBtmSheet.newInstance(ProxyCountriesBtmSheet.InputType.DOMAIN, cd, data, this) + bottomSheetFragment.show( + requireActivity().supportFragmentManager, + bottomSheetFragment.tag + ) + } + + private fun io(f: suspend () -> Unit) { + (requireContext() as LifecycleOwner).lifecycleScope.launch(Dispatchers.IO) { f() } + } + + private suspend fun uiCtx(f: suspend () -> Unit) { + withContext(Dispatchers.Main) { f() } + } + + override fun onDismissCC(obj: Any?) { + try { + val customDomain = obj as CustomDomain + cd = customDomain + updateStatusUi( + DomainRulesManager.Status.getStatus(cd.status), + cd.modifiedTs + ) + Logger.i(LOG_TAG_UI, "$TAG onDismissCC: ${cd.domain}, ${cd.proxyCC}") + } catch (e: Exception) { + Logger.e(LOG_TAG_UI, "$TAG err in onDismissCC ${e.message}", e) + } + } + + override fun onDismissWg(obj: Any?) { + try { + val customDomain = obj as CustomDomain + cd = customDomain + updateStatusUi( + DomainRulesManager.Status.getStatus(cd.status), + cd.modifiedTs + ) + Logger.i(LOG_TAG_UI, "$TAG onDismissWg: ${cd.domain}, ${cd.proxyId}") + } catch (e: Exception) { + Logger.e(LOG_TAG_UI, "$TAG err in onDismissWg ${e.message}", e) + } + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + Logger.v(LOG_TAG_UI, "$TAG onDismiss; domain: ${cd.domain}") + } + +} \ No newline at end of file diff --git a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/CustomIpRulesBtmSheet.kt b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/CustomIpRulesBtmSheet.kt new file mode 100644 index 000000000..d30620089 --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/CustomIpRulesBtmSheet.kt @@ -0,0 +1,428 @@ +package com.celzero.bravedns.ui.bottomsheet + +import Logger +import Logger.LOG_TAG_UI +import android.content.DialogInterface +import android.content.res.ColorStateList +import android.content.res.Configuration +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import com.celzero.bravedns.R +import com.celzero.bravedns.database.CustomIp +import com.celzero.bravedns.database.WgConfigFiles +import com.celzero.bravedns.database.WgConfigFilesImmutable +import com.celzero.bravedns.databinding.BottomSheetCustomIpsBinding +import com.celzero.bravedns.rpnproxy.RegionalWgConf +import com.celzero.bravedns.rpnproxy.RpnProxyManager +import com.celzero.bravedns.service.IpRulesManager +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.service.WireguardManager +import com.celzero.bravedns.util.Constants.Companion.UID_EVERYBODY +import com.celzero.bravedns.util.Themes.Companion.getBottomsheetCurrentTheme +import com.celzero.bravedns.util.UIUtils.fetchColor +import com.celzero.bravedns.util.UIUtils.fetchToggleBtnColors +import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.isAtleastQ +import com.celzero.bravedns.wireguard.Config +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.button.MaterialButton +import com.google.android.material.button.MaterialButtonToggleGroup +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koin.android.ext.android.inject + +class CustomIpRulesBtmSheet(private var ci: CustomIp) : + BottomSheetDialogFragment(), ProxyCountriesBtmSheet.CountriesDismissListener, WireguardListBtmSheet.WireguardDismissListener { + private var _binding: BottomSheetCustomIpsBinding? = null + + // This property is only valid between onCreateView and onDestroyView. + private val b + get() = _binding!! + + private val persistentState by inject() + + override fun getTheme(): Int = + getBottomsheetCurrentTheme(isDarkThemeOn(), persistentState.theme) + + private fun isDarkThemeOn(): Boolean { + return resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == + Configuration.UI_MODE_NIGHT_YES + } + + companion object { + private const val TAG = "CIRBtmSht" + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = BottomSheetCustomIpsBinding.inflate(inflater, container, false) + return b.root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + dialog?.window?.let { window -> + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + } + Logger.v(LOG_TAG_UI, "$TAG: onViewCreated for ${ci.ipAddress}") + init() + initClickListeners() + + b.chooseProxyCard.setOnClickListener { + val ctx = requireContext() + var v: MutableList = mutableListOf() + io { + v.add(null) + v.addAll(WireguardManager.getAllMappings()) + if (v.isEmpty() || v.size == 1) { + Logger.w(LOG_TAG_UI, "$TAG No Wireguard configs found") + uiCtx { + Utilities.showToastUiCentered( + ctx, + getString(R.string.wireguard_no_config_msg), + Toast.LENGTH_SHORT + ) + } + return@io + } + uiCtx { + Logger.v(LOG_TAG_UI, "$TAG show wg list(${v.size}) for ${ci.ipAddress}") + showWgListBtmSheet(v) + } + } + } + + b.chooseCountryCard.setOnClickListener { + io { + val ctrys = RpnProxyManager.getProtonUniqueCC() + if (ctrys.isEmpty()) { + Logger.w(LOG_TAG_UI, "$TAG No country codes found") + uiCtx { + Utilities.showToastUiCentered( + requireContext(), + "No ProtonVPN country codes found", + Toast.LENGTH_SHORT + ) + } + return@io + } + uiCtx { + Logger.v(LOG_TAG_UI, "$TAG show country list(${ctrys.size}) for ${ci.ipAddress}") + showProxyCountriesBtmSheet(ctrys) + } + } + } + } + + private fun init() { + val uid = ci.uid + Logger.v(LOG_TAG_UI, "$TAG: init for ${ci.ipAddress}, uid: $uid") + val rules = IpRulesManager.IpRuleStatus.getStatus(ci.status) + b.customIpTv.text = ci.ipAddress + showBypassUi(uid) + b.customIpToggleGroup.tag = 1 + updateToggleGroup(rules) + } + + private fun showBypassUi(uid: Int) { + if (uid == UID_EVERYBODY) { + b.customIpTgBypassUniv.visibility = View.VISIBLE + b.customIpTgBypassApp.visibility = View.GONE + } else { + b.customIpTgBypassUniv.visibility = View.GONE + b.customIpTgBypassApp.visibility = View.VISIBLE + } + } + + private fun initClickListeners() { + b.customIpToggleGroup.addOnButtonCheckedListener(ipRulesGroupListener) + + b.customIpDeleteChip.setOnClickListener { + showDialogForDelete() + } + } + + private val ipRulesGroupListener = + MaterialButtonToggleGroup.OnButtonCheckedListener { group, checkedId, isChecked -> + val b: MaterialButton = b.customIpToggleGroup.findViewById(checkedId) + val statusId = findSelectedIpRule(getTag(b.tag)) + if (statusId == null) { + Logger.i(LOG_TAG_UI, "$TAG: statusId is null") + return@OnButtonCheckedListener + } + + if (isChecked) { + // checked change listener is called multiple times, even for position change + // so, check if the status has changed or not + // also see CustomDomainAdapter#domainRulesGroupListener + val hasStatusChanged = ci.status != statusId.id + if (hasStatusChanged) { + val t = getToggleBtnUiParams(statusId) + // update the toggle button + selectToggleBtnUi(b, t) + + changeIpStatus(statusId, ci) + } else { + // no-op + } + } else { + unselectToggleBtnUi(b) + } + } + + private fun findSelectedIpRule(ruleId: Int): IpRulesManager.IpRuleStatus? { + return when (ruleId) { + IpRulesManager.IpRuleStatus.NONE.id -> { + IpRulesManager.IpRuleStatus.NONE + } + + IpRulesManager.IpRuleStatus.BLOCK.id -> { + IpRulesManager.IpRuleStatus.BLOCK + } + + IpRulesManager.IpRuleStatus.BYPASS_UNIVERSAL.id -> { + IpRulesManager.IpRuleStatus.BYPASS_UNIVERSAL + } + + IpRulesManager.IpRuleStatus.TRUST.id -> { + IpRulesManager.IpRuleStatus.TRUST + } + + else -> { + null + } + } + } + + private fun changeIpStatus(id: IpRulesManager.IpRuleStatus, customIp: CustomIp) { + io { + when (id) { + IpRulesManager.IpRuleStatus.NONE -> { + noRuleIp(customIp) + } + + IpRulesManager.IpRuleStatus.BLOCK -> { + blockIp(customIp) + } + + IpRulesManager.IpRuleStatus.BYPASS_UNIVERSAL -> { + byPassUniversal(customIp) + } + + IpRulesManager.IpRuleStatus.TRUST -> { + byPassAppRule(customIp) + } + } + } + } + + private suspend fun byPassUniversal(ci: CustomIp) { + Logger.v(LOG_TAG_UI, "$TAG: set ${ci.ipAddress} to bypass universal") + IpRulesManager.updateBypass(ci) + } + + private suspend fun byPassAppRule(ci: CustomIp) { + Logger.v(LOG_TAG_UI, "$TAG: set ${ci.ipAddress} to bypass app") + IpRulesManager.updateTrust(ci) + } + + private suspend fun blockIp(ci: CustomIp) { + Logger.v(LOG_TAG_UI, "$TAG: block ${ci.ipAddress}") + IpRulesManager.updateBlock(ci) + } + + private suspend fun noRuleIp(ci: CustomIp) { + Logger.v(LOG_TAG_UI, "$TAG: no rule for ${ci.ipAddress}") + IpRulesManager.updateNoRule(ci) + } + + private fun selectToggleBtnUi(btn: MaterialButton, toggleBtnUi: ToggleBtnUi) { + btn.setTextColor(toggleBtnUi.txtColor) + btn.backgroundTintList = ColorStateList.valueOf(toggleBtnUi.bgColor) + } + + private fun unselectToggleBtnUi(btn: MaterialButton) { + btn.setTextColor(fetchToggleBtnColors(requireContext(), R.color.defaultToggleBtnTxt)) + btn.backgroundTintList = + ColorStateList.valueOf( + fetchToggleBtnColors( + requireContext(), + R.color.defaultToggleBtnBg + ) + ) + } + + data class ToggleBtnUi(val txtColor: Int, val bgColor: Int) + + private fun getToggleBtnUiParams(id: IpRulesManager.IpRuleStatus): ToggleBtnUi { + return when (id) { + IpRulesManager.IpRuleStatus.NONE -> { + ToggleBtnUi( + fetchColor(requireContext(), R.attr.chipTextNeutral), + fetchColor(requireContext(), R.attr.chipBgColorNeutral) + ) + } + + IpRulesManager.IpRuleStatus.BLOCK -> { + ToggleBtnUi( + fetchColor(requireContext(), R.attr.chipTextNegative), + fetchColor(requireContext(), R.attr.chipBgColorNegative) + ) + } + + IpRulesManager.IpRuleStatus.BYPASS_UNIVERSAL -> { + ToggleBtnUi( + fetchColor(requireContext(), R.attr.chipTextPositive), + fetchColor(requireContext(), R.attr.chipBgColorPositive) + ) + } + + IpRulesManager.IpRuleStatus.TRUST -> { + ToggleBtnUi( + fetchColor(requireContext(), R.attr.chipTextPositive), + fetchColor(requireContext(), R.attr.chipBgColorPositive) + ) + } + } + } + + private fun updateToggleGroup(id: IpRulesManager.IpRuleStatus) { + val t = getToggleBtnUiParams(id) + + when (id) { + IpRulesManager.IpRuleStatus.NONE -> { + b.customIpToggleGroup.check(b.customIpTgNoRule.id) + selectToggleBtnUi(b.customIpTgNoRule, t) + unselectToggleBtnUi(b.customIpTgBlock) + unselectToggleBtnUi(b.customIpTgBypassUniv) + unselectToggleBtnUi(b.customIpTgBypassApp) + } + + IpRulesManager.IpRuleStatus.BLOCK -> { + b.customIpToggleGroup.check(b.customIpTgBlock.id) + selectToggleBtnUi(b.customIpTgBlock, t) + unselectToggleBtnUi(b.customIpTgNoRule) + unselectToggleBtnUi(b.customIpTgBypassUniv) + unselectToggleBtnUi(b.customIpTgBypassApp) + } + + IpRulesManager.IpRuleStatus.BYPASS_UNIVERSAL -> { + b.customIpToggleGroup.check(b.customIpTgBypassUniv.id) + selectToggleBtnUi(b.customIpTgBypassUniv, t) + unselectToggleBtnUi(b.customIpTgBlock) + unselectToggleBtnUi(b.customIpTgNoRule) + unselectToggleBtnUi(b.customIpTgBypassApp) + } + + IpRulesManager.IpRuleStatus.TRUST -> { + b.customIpToggleGroup.check(b.customIpTgBypassApp.id) + selectToggleBtnUi(b.customIpTgBypassApp, t) + unselectToggleBtnUi(b.customIpTgBlock) + unselectToggleBtnUi(b.customIpTgNoRule) + unselectToggleBtnUi(b.customIpTgBypassUniv) + } + } + } + + // each button in the toggle group is associated with tag value. + // tag values are ids of DomainRulesManager.DomainStatus + private fun getTag(tag: Any): Int { + return tag.toString().toIntOrNull() ?: 0 + } + + private fun showWgListBtmSheet(data: List) { + val bottomSheetFragment = WireguardListBtmSheet.newInstance(WireguardListBtmSheet.InputType.IP, ci, data, this) + bottomSheetFragment.show( + requireActivity().supportFragmentManager, + bottomSheetFragment.tag + ) + } + + private fun showProxyCountriesBtmSheet(data: List) { + Logger.v(LOG_TAG_UI, "$TAG: show pcc btm sheet for ${ci.ipAddress}") + val bottomSheetFragment = ProxyCountriesBtmSheet.newInstance(ProxyCountriesBtmSheet.InputType.IP, ci, data, this) + bottomSheetFragment.show( + requireActivity().supportFragmentManager, + bottomSheetFragment.tag + ) + } + + private fun showDialogForDelete() { + val builder = MaterialAlertDialogBuilder(requireContext()) + builder.setTitle(R.string.univ_firewall_dialog_title) + builder.setMessage(R.string.univ_firewall_dialog_message) + builder.setCancelable(true) + builder.setPositiveButton(requireContext().getString(R.string.lbl_delete)) { _, _ -> + io { IpRulesManager.removeIpRule(ci.uid, ci.ipAddress, ci.port) } + Utilities.showToastUiCentered( + requireContext(), + requireContext().getString(R.string.univ_ip_delete_individual_toast, ci.ipAddress), + Toast.LENGTH_SHORT + ) + dismiss() + } + + builder.setNegativeButton(requireContext().getString(R.string.lbl_cancel)) { _, _ -> + updateToggleGroup(IpRulesManager.IpRuleStatus.getStatus(ci.status)) + } + + builder.create().show() + } + + + override fun onDismissCC(obj: Any?) { + try { + val ci = obj as CustomIp + this.ci = ci + updateToggleGroup(IpRulesManager.IpRuleStatus.getStatus(ci.status)) + Logger.v(LOG_TAG_UI, "$TAG: onDismissCC: ${ci.ipAddress}, ${ci.proxyCC}") + } catch (e: Exception) { + Logger.w(LOG_TAG_UI, "$TAG: err in onDismissCC ${e.message}", e) + } + } + + override fun onDismissWg(obj: Any?) { + try { + val cip = obj as CustomIp + ci = cip + updateToggleGroup(IpRulesManager.IpRuleStatus.getStatus(cip.status)) + Logger.v(LOG_TAG_UI, "$TAG: onDismissWg: ${cip.ipAddress}, ${cip.proxyCC}") + } catch (e: Exception) { + Logger.w(LOG_TAG_UI, "$TAG: err in onDismissWg ${e.message}", e) + } + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + Logger.v(LOG_TAG_UI, "$TAG: onDismiss; ip: ${ci.ipAddress}") + } + + private fun io(f: suspend () -> Unit) { + (requireContext() as LifecycleOwner).lifecycleScope.launch(Dispatchers.IO) { f() } + } + + private suspend fun uiCtx(f: suspend () -> Unit) { + withContext(Dispatchers.Main) { f() } + } + +} \ No newline at end of file diff --git a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/DnsBlocklistBottomSheet.kt b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/DnsBlocklistBottomSheet.kt index 40c12109b..3529ad2e1 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/DnsBlocklistBottomSheet.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/DnsBlocklistBottomSheet.kt @@ -17,6 +17,7 @@ package com.celzero.bravedns.ui.bottomsheet import Logger import Logger.LOG_TAG_DNS +import android.content.Intent import android.content.res.ColorStateList import android.content.res.Configuration import android.graphics.drawable.Drawable @@ -31,6 +32,7 @@ import android.view.WindowManager import android.widget.AdapterView import android.widget.ImageView import androidx.appcompat.widget.AppCompatImageView +import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import com.bumptech.glide.Glide @@ -47,13 +49,18 @@ import com.celzero.bravedns.databinding.DialogInfoRulesLayoutBinding import com.celzero.bravedns.databinding.DialogIpDetailsLayoutBinding import com.celzero.bravedns.glide.FavIconDownloader import com.celzero.bravedns.service.DomainRulesManager +import com.celzero.bravedns.service.FirewallManager import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.ui.activity.DomainConnectionsActivity import com.celzero.bravedns.util.Constants import com.celzero.bravedns.util.ResourceRecordTypes import com.celzero.bravedns.util.Themes import com.celzero.bravedns.util.UIUtils.fetchColor import com.celzero.bravedns.util.UIUtils.updateHtmlEncodedText import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.getIcon +import com.celzero.bravedns.util.Utilities.isAtleastQ +import com.celzero.bravedns.viewmodel.DomainConnectionsViewModel import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.chip.Chip import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -62,6 +69,7 @@ import com.google.common.collect.Multimap import com.google.gson.Gson import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.koin.android.ext.android.inject import java.util.Locale @@ -112,6 +120,13 @@ class DnsBlocklistBottomSheet : BottomSheetDialogFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + dialog?.window?.let { window -> + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + } val data = arguments?.getString(INSTANCE_STATE_DNSLOGS) log = Gson().fromJson(data, DnsLog::class.java) @@ -123,17 +138,80 @@ class DnsBlocklistBottomSheet : BottomSheetDialogFragment() { } b.bsdlDomainRuleDesc.text = updateHtmlEncodedText(getString(R.string.bsdl_block_desc)) - b.dnsBlockUrl.text = log!!.queryStr + b.dnsBlockUrl.text = log!!.queryStr + " ❯" b.dnsBlockIpAddress.text = getResponseIp() b.dnsBlockConnectionFlag.text = log!!.flag - b.dnsBlockIpLatency.text = getString(R.string.dns_btm_latency_ms, log!!.latency.toString()) - b.dnsMessage.text = log!!.msg + b.dnsBlockIpLatency.text = getString(R.string.dns_btm_latency_ms, log!!.ttl.toString()) + if (Logger.LoggerLevel.fromId(persistentState.goLoggerLevel.toInt()) + .isLessThan(Logger.LoggerLevel.DEBUG) + ) { + b.dnsMessage.text = "${log?.msg}; ${log?.proxyId}; ${log?.relayIP}" + } else { + b.dnsMessage.text = log!!.msg + } displayFavIcon() displayDnsTransactionDetails() displayRecordTypeChip() setupClickListeners() + updateAppDetails(log) updateRulesUi(log!!.queryStr) + + if (log!!.region.isNotEmpty()) { + b.dnsRegion.visibility = View.VISIBLE + b.dnsRegion.text = log!!.region + } else { + b.dnsRegion.visibility = View.GONE + } + } + + private fun updateAppDetails(log: DnsLog?) { + if (log == null) { + b.dnsAppNameHeader.visibility = View.GONE + return + } + + if (log.appName.isNotEmpty() && log.packageName.isNotEmpty()) { + b.dnsAppNameHeader.visibility = View.VISIBLE + b.dnsAppName.text = log.appName + b.dnsAppIcon.setImageDrawable(getIcon(requireContext(), log.packageName, log.appName)) + return + } + + io { + val appNames = FirewallManager.getAppNamesByUid(log.uid) + if (appNames.isEmpty()) { + uiCtx { + b.dnsAppNameHeader.visibility = View.GONE + } + return@io + } + val pkgName = FirewallManager.getPackageNameByAppName(appNames[0]) + + val appCount = appNames.count() + uiCtx { + if (appCount >= 1) { + b.dnsAppName.text = + if (appCount >= 2) { + getString( + R.string.ctbs_app_other_apps, + appNames[0], + appCount.minus(1).toString() + ) + } else { + appNames[0] + } + if (pkgName == null) return@uiCtx + b.dnsAppIcon.setImageDrawable( + getIcon(requireContext(), pkgName, log.appName) + ) + } else { + // apps which are not available in cache are treated as non app. + // TODO: check packageManager#getApplicationInfo() for appInfo + b.dnsAppNameHeader.visibility = View.GONE + } + } + } } private fun getResponseIp(): String { @@ -164,6 +242,14 @@ class DnsBlocklistBottomSheet : BottomSheetDialogFragment() { private fun setupClickListeners() { + b.dnsBlockHeaderContainer.setOnClickListener { + startDomainConnectionsActivity(log!!.queryStr) + } + + b.dnsBlockUrl.setOnClickListener { + startDomainConnectionsActivity(log!!.queryStr) + } + b.bsdlDomainRuleSpinner.adapter = FirewallStatusSpinnerAdapter( requireContext(), @@ -346,6 +432,14 @@ class DnsBlocklistBottomSheet : BottomSheetDialogFragment() { } } + private fun startDomainConnectionsActivity(domain: String) { + val intent = Intent(requireContext(), DomainConnectionsActivity::class.java) + intent.putExtra(DomainConnectionsActivity.INTENT_EXTRA_TYPE, DomainConnectionsActivity.InputType.DOMAIN.type) + intent.putExtra(DomainConnectionsActivity.INTENT_EXTRA_DOMAIN, domain) + intent.putExtra(DomainConnectionsActivity.INTENT_EXTRA_TIME_CATEGORY, DomainConnectionsViewModel.TimeCategory.SEVEN_DAYS.value) + requireContext().startActivity(intent) + } + private fun showBlocklistDialog(groupNames: Multimap) { val dialogBinding = DialogInfoRulesLayoutBinding.inflate(layoutInflater) val builder = MaterialAlertDialogBuilder(requireContext()).setView(dialogBinding.root) @@ -436,25 +530,34 @@ class DnsBlocklistBottomSheet : BottomSheetDialogFragment() { if (log!!.isBlocked) { showBlockedState(uptime) } else { - showResolvedState(uptime, log!!.latency, log!!.groundedQuery()) + showResolvedState(uptime) } } - private fun showResolvedState(uptime: String, latency: Long, isGrounded: Boolean) { + private fun showResolvedState(uptime: String) { if (log == null) { Logger.w(LOG_TAG_DNS, "Transaction detail missing, no need to update ui") return } - if (latency == 0L && !isGrounded) { - b.dnsBlockBlockedDesc.text = + if (log!!.isCached) { + val txt = if (log!!.serverIP.isEmpty()) { getString(R.string.dns_btm_resolved_doh, uptime, getString(R.string.lbl_cache)) + } else { + val concat = "${getString(R.string.lbl_cache)} (${log!!.resolverId}:${log!!.serverIP})" + getString(R.string.dns_btm_resolved_doh, uptime, concat) + } + b.dnsBlockBlockedDesc.text = txt return } - if (log!!.isAnonymized()) { // anonymized queries answered by dns-crypt - val text = - getString(R.string.dns_btm_resolved_crypt, uptime, log!!.serverIP, log!!.relayIP) + if (log!!.isAnonymized()) { // anonymized queries answered by dns-crypt / proxies + val p = if (log!!.relayIP.isEmpty()) { + log!!.proxyId + } else { + log!!.relayIP + } + val text = getString(R.string.dns_btm_resolved_crypt, uptime, log!!.serverIP, p) b.dnsBlockBlockedDesc.text = updateHtmlEncodedText(text) } else if (log!!.isLocallyAnswered()) { // usually happens when there is a network failure b.dnsBlockBlockedDesc.text = getString(R.string.dns_btm_resolved_doh_no_server, uptime) @@ -608,4 +711,8 @@ class DnsBlocklistBottomSheet : BottomSheetDialogFragment() { private fun io(f: suspend () -> Unit) { lifecycleScope.launch(Dispatchers.IO) { f() } } + + private suspend fun uiCtx(f: suspend () -> Unit) { + withContext(Dispatchers.Main) { f() } + } } diff --git a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/FirewallAppFilterBottomSheet.kt b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/FirewallAppFilterBottomSheet.kt index 65d5352af..1fe0f4659 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/FirewallAppFilterBottomSheet.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/FirewallAppFilterBottomSheet.kt @@ -24,6 +24,7 @@ import android.view.View import android.view.ViewGroup import android.widget.CompoundButton import androidx.core.content.ContextCompat +import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.lifecycleScope import com.celzero.bravedns.R import com.celzero.bravedns.databinding.BottomSheetFirewallSortFilterBinding @@ -31,6 +32,7 @@ import com.celzero.bravedns.service.FirewallManager import com.celzero.bravedns.service.PersistentState import com.celzero.bravedns.ui.activity.AppListActivity import com.celzero.bravedns.util.Themes +import com.celzero.bravedns.util.Utilities.isAtleastQ import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.chip.Chip import kotlinx.coroutines.Dispatchers @@ -47,7 +49,7 @@ class FirewallAppFilterBottomSheet : BottomSheetDialogFragment() { get() = _binding!! private val persistentState by inject() - private val sortValues = AppListActivity.Filters() + private val filters = AppListActivity.Filters() override fun getTheme(): Int = Themes.getBottomsheetCurrentTheme(isDarkThemeOn(), persistentState.theme) @@ -63,28 +65,35 @@ class FirewallAppFilterBottomSheet : BottomSheetDialogFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + dialog?.window?.let { window -> + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + } initView() initClickListeners() } private fun initView() { - val filters = AppListActivity.filters.value + val f = AppListActivity.filters.value remakeParentFilterChipsUi() - if (filters == null) { + if (f == null) { applyParentFilter(AppListActivity.TopLevelFilter.ALL.id) return } else { - sortValues.firewallFilter = filters.firewallFilter + this.filters.firewallFilter = f.firewallFilter } - applyParentFilter(filters.topLevelFilter.id) - setFilter(filters.topLevelFilter, filters.categoryFilters) + applyParentFilter(f.topLevelFilter.id) + setFilter(f.topLevelFilter, f.categoryFilters) } private fun initClickListeners() { b.fsApply.setOnClickListener { - AppListActivity.filters.postValue(sortValues) + AppListActivity.filters.postValue(filters) this.dismiss() } @@ -173,24 +182,24 @@ class FirewallAppFilterBottomSheet : BottomSheetDialogFragment() { private fun applyParentFilter(tag: Any) { when (tag) { AppListActivity.TopLevelFilter.ALL.id -> { - sortValues.topLevelFilter = AppListActivity.TopLevelFilter.ALL - sortValues.categoryFilters.clear() + filters.topLevelFilter = AppListActivity.TopLevelFilter.ALL + filters.categoryFilters.clear() io { val categories = FirewallManager.getAllCategories() uiCtx { remakeChildFilterChipsUi(categories) } } } AppListActivity.TopLevelFilter.INSTALLED.id -> { - sortValues.topLevelFilter = AppListActivity.TopLevelFilter.INSTALLED - sortValues.categoryFilters.clear() + filters.topLevelFilter = AppListActivity.TopLevelFilter.INSTALLED + filters.categoryFilters.clear() io { val categories = FirewallManager.getCategoriesForInstalledApps() uiCtx { remakeChildFilterChipsUi(categories) } } } AppListActivity.TopLevelFilter.SYSTEM.id -> { - sortValues.topLevelFilter = AppListActivity.TopLevelFilter.SYSTEM - sortValues.categoryFilters.clear() + filters.topLevelFilter = AppListActivity.TopLevelFilter.SYSTEM + filters.categoryFilters.clear() io { val categories = FirewallManager.getCategoriesForSystemApps() uiCtx { remakeChildFilterChipsUi(categories) } @@ -220,9 +229,9 @@ class FirewallAppFilterBottomSheet : BottomSheetDialogFragment() { private fun applyChildFilter(tag: Any, show: Boolean) { if (show) { - sortValues.categoryFilters.add(tag.toString()) + filters.categoryFilters.add(tag.toString()) } else { - sortValues.categoryFilters.remove(tag.toString()) + filters.categoryFilters.remove(tag.toString()) } } diff --git a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/HomeScreenSettingBottomSheet.kt b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/HomeScreenSettingBottomSheet.kt index 0f1002e43..adf946e08 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/HomeScreenSettingBottomSheet.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/HomeScreenSettingBottomSheet.kt @@ -19,13 +19,16 @@ import Logger import Logger.LOG_TAG_VPN import android.content.res.Configuration import android.os.Bundle +import android.os.SystemClock import android.text.format.DateUtils import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.CompoundButton +import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.lifecycleScope import com.celzero.bravedns.R +import com.celzero.bravedns.RethinkDnsApplication.Companion.DEBUG import com.celzero.bravedns.data.AppConfig import com.celzero.bravedns.databinding.BottomSheetHomeScreenBinding import com.celzero.bravedns.service.PersistentState @@ -33,10 +36,12 @@ import com.celzero.bravedns.service.VpnController import com.celzero.bravedns.util.Constants.Companion.INIT_TIME_MS import com.celzero.bravedns.util.Themes.Companion.getBottomsheetCurrentTheme import com.celzero.bravedns.util.UIUtils.openVpnProfile +import com.celzero.bravedns.util.Utilities.isAtleastQ import com.google.android.material.bottomsheet.BottomSheetDialogFragment import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.koin.android.ext.android.inject +import kotlin.math.abs class HomeScreenSettingBottomSheet : BottomSheetDialogFragment() { private var _binding: BottomSheetHomeScreenBinding? = null @@ -73,6 +78,13 @@ class HomeScreenSettingBottomSheet : BottomSheetDialogFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + dialog?.window?.let { window -> + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + } initView() updateUptime() initializeClickListeners() @@ -82,7 +94,6 @@ class HomeScreenSettingBottomSheet : BottomSheetDialogFragment() { b.bsHomeScreenConnectedStatus.text = getConnectionStatus() val selectedIndex = appConfig.getBraveMode().mode Logger.d(LOG_TAG_VPN, "Home screen bottom sheet selectedIndex: $selectedIndex") - updateStatus(selectedIndex) } @@ -163,7 +174,7 @@ class HomeScreenSettingBottomSheet : BottomSheetDialogFragment() { b.bsHsFirewallRl.alpha = 0.5f setRadioButtonsEnabled(false) } else if (isProxyEnabled) { - b.bsHomeScreenVpnLockdownDesc.text = getString(R.string.settings_lock_down_proxy_desc) + b.bsHomeScreenVpnLockdownDesc.text = getString(R.string.mode_change_error_proxy_enabled) b.bsHomeScreenVpnLockdownDesc.visibility = View.VISIBLE b.bsHsDnsRl.alpha = 0.5f b.bsHsFirewallRl.alpha = 0.5f diff --git a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/LocalBlocklistsBottomSheet.kt b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/LocalBlocklistsBottomSheet.kt index 8bfda3062..a7dd99948 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/LocalBlocklistsBottomSheet.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/LocalBlocklistsBottomSheet.kt @@ -29,6 +29,7 @@ import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.AppCompatTextView import androidx.core.content.ContextCompat +import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.lifecycleScope import androidx.work.WorkInfo import androidx.work.WorkManager @@ -44,19 +45,24 @@ import com.celzero.bravedns.ui.activity.ConfigureRethinkBasicActivity import com.celzero.bravedns.ui.fragment.DnsSettingsFragment import com.celzero.bravedns.util.Constants import com.celzero.bravedns.util.Constants.Companion.INIT_TIME_MS +import com.celzero.bravedns.util.Constants.Companion.LOCAL_BLOCKLIST_DOWNLOAD_FOLDER_NAME import com.celzero.bravedns.util.Constants.Companion.RETHINK_SEARCH_URL import com.celzero.bravedns.util.Themes.Companion.getBottomsheetCurrentTheme import com.celzero.bravedns.util.UIUtils.clipboardCopy import com.celzero.bravedns.util.UIUtils.fetchToggleBtnColors import com.celzero.bravedns.util.UIUtils.openUrl import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.blocklistCanonicalPath import com.celzero.bravedns.util.Utilities.convertLongToTime +import com.celzero.bravedns.util.Utilities.deleteRecursive +import com.celzero.bravedns.util.Utilities.isAtleastQ import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.android.ext.android.inject +import java.io.File class LocalBlocklistsBottomSheet : BottomSheetDialogFragment() { private var _binding: BottomSheetLocalBlocklistsBinding? = null @@ -107,6 +113,13 @@ class LocalBlocklistsBottomSheet : BottomSheetDialogFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + dialog?.window?.let { window -> + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + } updateLocalBlocklistUi() init() initializeObservers() @@ -124,7 +137,7 @@ class LocalBlocklistsBottomSheet : BottomSheetDialogFragment() { b.lbbsVersion.text = getString( R.string.settings_local_blocklist_version, - Utilities.convertLongToTime( + convertLongToTime( persistentState.localBlocklistTimestamp, Constants.TIME_FORMAT_2 ) @@ -255,6 +268,20 @@ class LocalBlocklistsBottomSheet : BottomSheetDialogFragment() { alertDialog.show() } + private fun showDeleteDialog() { + val builder = MaterialAlertDialogBuilder(requireContext()) + builder.setTitle(R.string.lbl_delete) + builder.setMessage(getString(R.string.local_blocklist_delete_desc)) + builder.setCancelable(false) + builder.setPositiveButton(getString(R.string.settings_local_blocklist_dialog_positive)) { + _: DialogInterface, _: Int -> + deleteLocalBlocklist() + } + builder.setNegativeButton(getString(R.string.lbl_cancel)) { dialog, _ -> dialog.dismiss() } + val alertDialog: AlertDialog = builder.create() + alertDialog.show() + } + private fun downloadLocalBlocklist(isRedownload: Boolean) { ui { var status = AppDownloadManager.DownloadManagerStatus.NOT_STARTED @@ -267,6 +294,34 @@ class LocalBlocklistsBottomSheet : BottomSheetDialogFragment() { } } + private fun deleteLocalBlocklist() { + ui { + b.lbbsDelete.isEnabled = false + b.lbbsDownload.isEnabled = false + b.lbbsRedownload.isEnabled = false + b.lbbsCheckDownload.isEnabled = false + + ioCtx { + // delete the whole local blocklist folder + val path = + blocklistCanonicalPath(requireContext(), LOCAL_BLOCKLIST_DOWNLOAD_FOLDER_NAME) + val dir = File(path) + deleteRecursive(dir) + persistentState.localBlocklistTimestamp = INIT_TIME_MS + persistentState.localBlocklistStamp = "" + persistentState.newestLocalBlocklistTimestamp = INIT_TIME_MS + } + + updateLocalBlocklistUi() + showCheckUpdateUi() + Utilities.showToastUiCentered( + requireContext(), + getString(R.string.config_add_success_toast), + Toast.LENGTH_SHORT + ) + } + } + private fun handleDownloadStatus(status: AppDownloadManager.DownloadManagerStatus) { when (status) { AppDownloadManager.DownloadManagerStatus.IN_PROGRESS -> { @@ -323,7 +378,7 @@ class LocalBlocklistsBottomSheet : BottomSheetDialogFragment() { // TODO: prompt user for app update Utilities.showToastUiCentered( requireContext(), - "Download latest version to update the blocklists", + getString(R.string.blocklist_not_available_toast), Toast.LENGTH_SHORT ) } @@ -366,7 +421,7 @@ class LocalBlocklistsBottomSheet : BottomSheetDialogFragment() { b.lbbsEnable.text = getString(R.string.lbl_disabled) b.lbbsEnable.setTextColor(fetchToggleBtnColors(requireContext(), R.color.accentBad)) b.lbbsHeading.text = getString(R.string.lbbs_heading) - setDrawable(R.drawable.ic_cross, b.lbbsEnable) + setDrawable(R.drawable.ic_cross_accent, b.lbbsEnable) b.lbbsConfigure.isEnabled = false b.lbbsCopy.isEnabled = false @@ -411,6 +466,8 @@ class LocalBlocklistsBottomSheet : BottomSheetDialogFragment() { } b.lbbsRedownload.setOnClickListener { showDownloadDialog(isRedownload = true) } + + b.lbbsDelete.setOnClickListener { showDeleteDialog() } } private fun isBlocklistUpdateAvailable() { diff --git a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/OrbotBottomSheet.kt b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/OrbotBottomSheet.kt index a9e767475..ce8637ccf 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/OrbotBottomSheet.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/OrbotBottomSheet.kt @@ -29,6 +29,7 @@ import android.view.animation.AccelerateInterpolator import android.view.animation.Animation import android.widget.Toast import androidx.core.text.HtmlCompat +import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.lifecycleScope import com.celzero.bravedns.R import com.celzero.bravedns.adapter.WgIncludeAppsAdapter @@ -92,6 +93,13 @@ class OrbotBottomSheet : BottomSheetDialogFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + dialog?.window?.let { window -> + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + } initView() observeApps() setupClickListeners() @@ -547,7 +555,6 @@ class OrbotBottomSheet : BottomSheetDialogFragment() { private fun gotoDnsConfigureScreen() { this.dismiss() val intent = Intent(requireContext(), DnsDetailActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED intent.putExtra( Constants.VIEW_PAGER_SCREEN_TO_LOAD, DnsDetailActivity.Tabs.CONFIGURE.screen diff --git a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/ProxyCountriesBtmSheet.kt b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/ProxyCountriesBtmSheet.kt new file mode 100644 index 000000000..492951f3c --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/ProxyCountriesBtmSheet.kt @@ -0,0 +1,270 @@ +package com.celzero.bravedns.ui.bottomsheet + +import Logger +import Logger.LOG_TAG_UI +import android.content.DialogInterface +import android.content.res.Configuration +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.celzero.bravedns.database.AppInfo +import com.celzero.bravedns.database.CustomDomain +import com.celzero.bravedns.database.CustomIp +import com.celzero.bravedns.databinding.BottomSheetProxiesListBinding +import com.celzero.bravedns.databinding.ListItemProxyCcWgBinding +import com.celzero.bravedns.rpnproxy.RegionalWgConf +import com.celzero.bravedns.rpnproxy.RpnProxyManager +import com.celzero.bravedns.service.DomainRulesManager +import com.celzero.bravedns.service.IpRulesManager +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.util.Themes.Companion.getBottomsheetCurrentTheme +import com.celzero.bravedns.util.UIUtils.getCountryNameFromFlag +import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.getFlag +import com.celzero.bravedns.util.Utilities.isAtleastQ +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koin.android.ext.android.inject + +class ProxyCountriesBtmSheet(val type: InputType, val obj: Any?, val confs: List, val listener: CountriesDismissListener) : + BottomSheetDialogFragment() { + private var _binding: BottomSheetProxiesListBinding? = null + + // This property is only valid between onCreateView and onDestroyView. + private val b + get() = _binding!! + + private val persistentState by inject() + + private val cd: CustomDomain? = if (type == InputType.DOMAIN) obj as CustomDomain else null + private val ci: CustomIp? = if (type == InputType.IP) obj as CustomIp else null + private val ai: AppInfo? = if (type == InputType.APP) obj as AppInfo else null + + enum class InputType(val id: Int) { + DOMAIN (0), + IP (1), + APP (2) + } + + companion object { + fun newInstance(input: InputType, obj: Any?, data: List, listener: CountriesDismissListener): ProxyCountriesBtmSheet { + return ProxyCountriesBtmSheet(input, obj, data, listener) + } + + private const val TAG = "PCCBtmSheet" + } + + interface CountriesDismissListener { + fun onDismissCC(obj: Any?) + } + + override fun getTheme(): Int = + getBottomsheetCurrentTheme(isDarkThemeOn(), persistentState.theme) + + private fun isDarkThemeOn(): Boolean { + return resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == + Configuration.UI_MODE_NIGHT_YES + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = BottomSheetProxiesListBinding.inflate(inflater, container, false) + return b.root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + dialog?.window?.let { window -> + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + } + init() + } + + private fun init() { + b.title.text = "Select Proxy Country" + b.recyclerView.layoutManager = LinearLayoutManager(requireContext()) + + when (type) { + InputType.IP -> { + b.ipDomainInfo.visibility = View.VISIBLE + b.ipDomainInfo.text = ci?.ipAddress + } + InputType.DOMAIN -> { + b.ipDomainInfo.visibility = View.VISIBLE + b.ipDomainInfo.text = cd?.domain + } + InputType.APP -> { + // TODO: Implement this + } + } + + val lst = confs.map { it } + val adapter = RecyclerViewAdapter(lst) { conf -> + handleOnItemClicked(conf) + } + + b.recyclerView.adapter = adapter + } + + private fun handleOnItemClicked(conf: RegionalWgConf) { + Logger.v(LOG_TAG_UI, "$TAG: Item clicked: ${conf.name}") + Logger.v(LOG_TAG_UI, "$TAG: country selected: $conf") + // TODO: Implement the action to be taken when an item is selected + // returns a pair of boolean and error message + val pair = RpnProxyManager.canSelectCountryCode(conf.cc) + if (!pair.first) { + Utilities.showToastUiCentered( + requireContext(), + pair.second, + Toast.LENGTH_SHORT + ) + Logger.w(LOG_TAG_UI, "$TAG: err on selecting cc: ${pair.second}") + return + } + io { + when (type) { + InputType.DOMAIN -> { + processDomain(conf) + } + InputType.IP -> { + processIp(conf) + } + InputType.APP -> { + // processApp(config) + } + } + } + } + + private suspend fun processDomain(conf: RegionalWgConf) { + if (cd == null) { + Logger.w(LOG_TAG_UI, "$TAG: custom domain is null") + return + } + DomainRulesManager.setCC(cd, conf.cc) + cd.proxyCC = conf.cc + uiCtx { + Utilities.showToastUiCentered( + requireContext(), + "Country code updated for ${cd.domain}", + Toast.LENGTH_SHORT + ) + } + } + + private suspend fun processIp(conf: RegionalWgConf) { + if (ci == null) { + Logger.w(LOG_TAG_UI, "$TAG: custom ip is null") + return + } + IpRulesManager.updateProxyCC(ci, conf.cc) + ci.proxyCC = conf.cc + uiCtx { + Utilities.showToastUiCentered( + requireContext(), + "Country code updated for ${ci.ipAddress}", + Toast.LENGTH_SHORT + ) + } + } + + inner class RecyclerViewAdapter( + private val data: List, + private val onItemClicked: (RegionalWgConf) -> Unit + ) : RecyclerView.Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = + ListItemProxyCcWgBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(data[position]) + } + + override fun getItemCount(): Int = data.size + + inner class ViewHolder(private val bb: ListItemProxyCcWgBinding) : + RecyclerView.ViewHolder(bb.root) { + + fun bind(conf: RegionalWgConf) { + Logger.v(LOG_TAG_UI, "$TAG: binding item: ${conf.cc}, ${conf.name}") + val flag = getFlag(conf.cc) + val ccName = conf.name.ifEmpty { getCountryNameFromFlag(flag) } + when (type) { + InputType.DOMAIN -> { + bb.proxyNameCc.text = conf.cc + bb.proxyIconCc.text = flag + bb.proxyRadioCc.isChecked = conf.cc == cd?.proxyCC + bb.proxyDescCc.text = ccName + } + InputType.IP -> { + bb.proxyNameCc.text = conf.cc + bb.proxyIconCc.text = flag + bb.proxyRadioCc.isChecked = conf.cc == ci?.proxyCC + bb.proxyDescCc.text = ccName + } + InputType.APP -> { + // TODO: Implement this + } + } + + bb.proxyRadioCc.setOnClickListener { + notifyDataSetChanged() + onItemClicked(conf) + } + + bb.lipCcWgParent.setOnClickListener { + notifyDataSetChanged() + onItemClicked(conf) + } + } + } + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + when (type) { + InputType.DOMAIN -> { + listener.onDismissCC(cd) + } + InputType.IP -> { + listener.onDismissCC(ci) + } + InputType.APP -> { + listener.onDismissCC(ai) + } + } + } + + private fun io(f: suspend () -> Unit) { + (requireContext() as LifecycleOwner).lifecycleScope.launch(Dispatchers.IO) { f() } + } + + private suspend fun uiCtx(f: suspend () -> Unit) { + withContext(Dispatchers.Main) { f() } + } + +} \ No newline at end of file diff --git a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/RethinkListBottomSheet.kt b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/RethinkListBottomSheet.kt index e9a08b87b..503241cbf 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/RethinkListBottomSheet.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/RethinkListBottomSheet.kt @@ -21,6 +21,7 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.view.WindowInsetsControllerCompat import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.celzero.bravedns.adapter.RethinkEndpointAdapter @@ -28,6 +29,7 @@ import com.celzero.bravedns.databinding.BottomSheetRethinkListBinding import com.celzero.bravedns.service.PersistentState import com.celzero.bravedns.ui.activity.ConfigureRethinkBasicActivity import com.celzero.bravedns.util.Themes +import com.celzero.bravedns.util.Utilities.isAtleastQ import com.celzero.bravedns.viewmodel.RethinkEndpointViewModel import com.google.android.material.bottomsheet.BottomSheetDialogFragment import org.koin.android.ext.android.get @@ -69,6 +71,13 @@ class RethinkListBottomSheet : BottomSheetDialogFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + dialog?.window?.let { window -> + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + } initView() initClickListeners() } diff --git a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/RethinkLogBottomSheet.kt b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/RethinkLogBottomSheet.kt index 4b0a71e8a..d923b9f51 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/RethinkLogBottomSheet.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/RethinkLogBottomSheet.kt @@ -29,6 +29,7 @@ import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.core.content.ContextCompat +import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.lifecycleScope import com.celzero.bravedns.R import com.celzero.bravedns.database.ConnectionTracker @@ -47,6 +48,7 @@ import com.celzero.bravedns.util.UIUtils.fetchColor import com.celzero.bravedns.util.UIUtils.updateHtmlEncodedText import com.celzero.bravedns.util.Utilities import com.celzero.bravedns.util.Utilities.getIcon +import com.celzero.bravedns.util.Utilities.isAtleastQ import com.celzero.bravedns.util.Utilities.showToastUiCentered import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.gson.Gson @@ -93,6 +95,13 @@ class RethinkLogBottomSheet : BottomSheetDialogFragment(), KoinComponent { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + dialog?.window?.let { window -> + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + } val data = arguments?.getString(INSTANCE_STATE_IPDETAILS) info = Gson().fromJson(data, RethinkLog::class.java) initView() @@ -382,7 +391,7 @@ class RethinkLogBottomSheet : BottomSheetDialogFragment(), KoinComponent { private fun openAppDetailActivity(uid: Int) { this.dismiss() val intent = Intent(requireContext(), AppInfoActivity::class.java) - intent.putExtra(AppInfoActivity.UID_INTENT_NAME, uid) + intent.putExtra(AppInfoActivity.INTENT_UID, uid) requireContext().startActivity(intent) } diff --git a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/RethinkPlusFilterBottomSheet.kt b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/RethinkPlusFilterBottomSheet.kt index 03a80cb0d..898e4b80a 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/RethinkPlusFilterBottomSheet.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/RethinkPlusFilterBottomSheet.kt @@ -24,12 +24,14 @@ import android.view.View import android.view.ViewGroup import android.widget.CompoundButton import androidx.core.content.ContextCompat +import androidx.core.view.WindowInsetsControllerCompat import com.celzero.bravedns.R import com.celzero.bravedns.data.FileTag import com.celzero.bravedns.databinding.BottomSheetRethinkPlusFilterBinding import com.celzero.bravedns.service.PersistentState import com.celzero.bravedns.ui.fragment.RethinkBlocklistFragment import com.celzero.bravedns.util.Themes +import com.celzero.bravedns.util.Utilities.isAtleastQ import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.chip.Chip import org.koin.android.ext.android.inject @@ -68,6 +70,13 @@ class RethinkPlusFilterBottomSheet( override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + dialog?.window?.let { window -> + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + } initView() initClickListeners() } diff --git a/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/WireguardListBtmSheet.kt b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/WireguardListBtmSheet.kt new file mode 100644 index 000000000..b71c517a4 --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/ui/bottomsheet/WireguardListBtmSheet.kt @@ -0,0 +1,285 @@ +package com.celzero.bravedns.ui.bottomsheet + +import Logger +import Logger.LOG_TAG_UI +import android.content.DialogInterface +import android.content.res.Configuration +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.celzero.bravedns.R +import com.celzero.bravedns.database.AppInfo +import com.celzero.bravedns.database.CustomDomain +import com.celzero.bravedns.database.CustomIp +import com.celzero.bravedns.database.WgConfigFilesImmutable +import com.celzero.bravedns.databinding.BottomSheetProxiesListBinding +import com.celzero.bravedns.databinding.ListItemProxyCcWgBinding +import com.celzero.bravedns.service.DomainRulesManager +import com.celzero.bravedns.service.IpRulesManager +import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.service.ProxyManager.ID_WG_BASE +import com.celzero.bravedns.util.Themes.Companion.getBottomsheetCurrentTheme +import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.isAtleastQ +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koin.android.ext.android.inject + +class WireguardListBtmSheet(val type: InputType, val obj: Any?, val confs: List, val listener: WireguardDismissListener) : + BottomSheetDialogFragment() { + private var _binding: BottomSheetProxiesListBinding? = null + + // This property is only valid between onCreateView and onDestroyView. + private val b + get() = _binding!! + + private val persistentState by inject() + + private val cd: CustomDomain? = if (type == InputType.DOMAIN) obj as CustomDomain else null + private val ci: CustomIp? = if (type == InputType.IP) obj as CustomIp else null + private val ai: AppInfo? = if (type == InputType.APP) obj as AppInfo else null + + companion object { + fun newInstance(input: InputType, obj: Any?, data: List, listener: WireguardDismissListener): WireguardListBtmSheet { + return WireguardListBtmSheet(input, obj, data, listener) + } + + private const val TAG = "WglBtmSht" + } + + interface WireguardDismissListener { + fun onDismissWg(obj: Any?) + } + + enum class InputType(val id: Int) { + DOMAIN (0), + IP (1), + APP (2) + } + + override fun getTheme(): Int = + getBottomsheetCurrentTheme(isDarkThemeOn(), persistentState.theme) + + private fun isDarkThemeOn(): Boolean { + return resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == + Configuration.UI_MODE_NIGHT_YES + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = BottomSheetProxiesListBinding.inflate(inflater, container, false) + return b.root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + dialog?.window?.let { window -> + if (isAtleastQ()) { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.isAppearanceLightNavigationBars = false + window.isNavigationBarContrastEnforced = false + } + } + Logger.v(LOG_TAG_UI, "$TAG: view created") + init() + } + + private fun init() { + b.title.text = getString(R.string.select_wireguard_proxy) + when (type) { + InputType.DOMAIN -> { + b.ipDomainInfo.visibility = View.VISIBLE + b.ipDomainInfo.text = cd?.domain + } + InputType.IP -> { + b.ipDomainInfo.visibility = View.VISIBLE + b.ipDomainInfo.text = ci?.ipAddress + } + InputType.APP -> { + // initApp() + } + } + + val lst = confs.map { it } + b.recyclerView.layoutManager = LinearLayoutManager(requireContext()) + val adapter = RecyclerViewAdapter(lst) { conf -> + Logger.v(LOG_TAG_UI, "$TAG: Item clicked: ${conf?.name ?: "None"}") + when (type) { + InputType.DOMAIN -> { + processDomain(conf) + } + InputType.IP -> { + processIp(conf) + } + InputType.APP -> { + // processApp(selectedItem) + } + } + } + + b.recyclerView.adapter = adapter + } + + private fun processDomain(conf: WgConfigFilesImmutable?) { + io { + if (cd == null) { + Logger.w(LOG_TAG_UI, "$TAG: Custom domain is null") + return@io + } + if (conf == null) { + DomainRulesManager.setProxyId(cd, "") + cd.proxyId = "" + } else { + val id = ID_WG_BASE + conf.id + DomainRulesManager.setProxyId(cd, id) + cd.proxyId = id + } + val name = conf?.name ?: getString(R.string.settings_app_list_default_app) + Logger.v(LOG_TAG_UI, "$TAG: wg-endpoint set to $name for ${cd.domain}") + uiCtx { + Utilities.showToastUiCentered( + requireContext(), + getString(R.string.config_add_success_toast), + Toast.LENGTH_SHORT + ) + } + } + } + + private fun processIp(conf: WgConfigFilesImmutable?) { + io { + if (ci == null) { + Logger.w(LOG_TAG_UI, "$TAG: Custom IP is null") + return@io + } + if (conf == null) { + IpRulesManager.updateProxyId(ci, "") + ci.proxyId = "" + } else { + val id = ID_WG_BASE + conf.id + IpRulesManager.updateProxyId(ci, id) + ci.proxyId = id + } + val name = conf?.name ?: getString(R.string.settings_app_list_default_app) + Logger.v(LOG_TAG_UI, "$TAG: wg-endpoint set to $name for ${ci.ipAddress}") + uiCtx { + Utilities.showToastUiCentered( + requireContext(), + getString(R.string.config_add_success_toast), + Toast.LENGTH_SHORT + ) + } + } + } + + inner class RecyclerViewAdapter( + private val data: List, + private val onItemClicked: (WgConfigFilesImmutable?) -> Unit + ) : RecyclerView.Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = + ListItemProxyCcWgBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(data[position]) + } + + override fun getItemCount(): Int = data.size + + inner class ViewHolder(private val bb: ListItemProxyCcWgBinding) : + RecyclerView.ViewHolder(bb.root) { + + fun bind(conf: WgConfigFilesImmutable?) { + if (conf == null) { + bb.proxyNameCc.text = getString(R.string.settings_app_list_default_app) + bb.proxyDescCc.text = getString(R.string.settings_app_list_default_app) + when (type) { + InputType.DOMAIN -> { + bb.proxyRadioCc.isChecked = cd?.proxyId?.isEmpty() == true + } + + InputType.IP -> { + bb.proxyRadioCc.isChecked = ci?.proxyId?.isEmpty() == true + } + + InputType.APP -> { + // bb.endpointCheck.isChecked = item == appInfo?.proxyId + } + } + } else { + bb.proxyNameCc.text = conf.name + bb.proxyDescCc.text = if (conf.isActive) getString(R.string.lbl_active) else getString(R.string.lbl_inactive) + + val id = ID_WG_BASE + conf.id + when (type) { + InputType.DOMAIN -> { + bb.proxyRadioCc.isChecked = id == cd?.proxyId + } + + InputType.IP -> { + bb.proxyRadioCc.isChecked = id == ci?.proxyId + } + + InputType.APP -> { + // bb.endpointCheck.isChecked = item == appInfo?.proxyId + } + } + } + bb.lipCcWgParent.setOnClickListener { + onItemClicked(conf) + notifyDataSetChanged() + } + + bb.proxyRadioCc.setOnClickListener { + onItemClicked(conf) + notifyDataSetChanged() + } + } + } + } + + private fun io(f: suspend () -> Unit) { + (requireContext() as LifecycleOwner).lifecycleScope.launch(Dispatchers.IO) { f() } + } + + private suspend fun uiCtx(f: suspend () -> Unit) { + withContext(Dispatchers.Main) { f() } + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + Logger.v(LOG_TAG_UI, "$TAG: Dismissed, input: ${type.name}") + when (type) { + InputType.DOMAIN -> { + listener.onDismissWg(cd) + } + InputType.IP -> { + listener.onDismissWg(ci) + } + InputType.APP -> { + listener.onDismissWg(ai) + } + } + } + +} diff --git a/app/src/full/java/com/celzero/bravedns/ui/dialog/SubscriptionAnimDialog.kt b/app/src/full/java/com/celzero/bravedns/ui/dialog/SubscriptionAnimDialog.kt new file mode 100644 index 000000000..6e74b2faf --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/ui/dialog/SubscriptionAnimDialog.kt @@ -0,0 +1,74 @@ +package com.celzero.bravedns.ui.dialog + +import android.graphics.Color +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.graphics.drawable.toDrawable +import androidx.fragment.app.DialogFragment +import by.kirich1409.viewbindingdelegate.viewBinding +import com.celzero.bravedns.R +import com.celzero.bravedns.databinding.DialogSubscriptionAnimBinding +import nl.dionsegijn.konfetti.core.Angle +import nl.dionsegijn.konfetti.core.Party +import nl.dionsegijn.konfetti.core.Position +import nl.dionsegijn.konfetti.core.Rotation +import nl.dionsegijn.konfetti.core.emitter.Emitter +import nl.dionsegijn.konfetti.core.models.Shape +import nl.dionsegijn.konfetti.core.models.Size +import java.util.concurrent.TimeUnit + +class SubscriptionAnimDialog : DialogFragment() { + private val b by viewBinding(DialogSubscriptionAnimBinding::bind) + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.dialog_subscription_anim, container, false) + } + + override fun onStart() { + super.onStart() + dialog?.window?.setBackgroundDrawable(Color.TRANSPARENT.toDrawable()) + dialog?.setCancelable(true) + b.konfettiView.start(festive()) + b.konfettiView.postDelayed({ + dismiss() + }, 2000L) + } + + private fun festive(): List { + val party = Party( + speed = 30f, + maxSpeed = 50f, + damping = 0.9f, + angle = Angle.TOP, + spread = 45, + size = listOf(Size.SMALL, Size.LARGE, Size.LARGE, Size.LARGE, Size.LARGE, Size.LARGE, Size.LARGE, Size.LARGE, Size.LARGE, Size.LARGE), + shapes = listOf(Shape.Square, Shape.Circle, Shape.Circle, Shape.Circle, Shape.Circle, Shape.Circle, Shape.Circle, Shape.Circle, Shape.Circle, Shape.Circle), + timeToLive = 3000L, + rotation = Rotation(), + colors = listOf(0xf0efe4, 0xe6e5de, 0xf4306d, 0xfbfbf7, 0xd8d6c2, 0xf0efe4, 0xe6e5de, 0xf4306d, 0xfbfbf7, 0xd8d6c2), + emitter = Emitter(duration = 100, TimeUnit.MILLISECONDS).max(30), + position = Position.Relative(0.5, 1.0) + ) + + return listOf( + party, + party.copy( + speed = 55f, + maxSpeed = 65f, + spread = 10, + emitter = Emitter(duration = 100, TimeUnit.MILLISECONDS).max(10), + ), + party.copy( + speed = 65f, + maxSpeed = 80f, + spread = 10, + emitter = Emitter(duration = 100, TimeUnit.MILLISECONDS).max(10), + ) + ) + } + +} diff --git a/app/src/full/java/com/celzero/bravedns/ui/dialog/WgAddPeerDialog.kt b/app/src/full/java/com/celzero/bravedns/ui/dialog/WgAddPeerDialog.kt index 3f91c291b..cb385208b 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/dialog/WgAddPeerDialog.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/dialog/WgAddPeerDialog.kt @@ -22,8 +22,6 @@ import android.os.Bundle import android.view.View import android.view.Window import android.view.WindowManager -import android.view.inputmethod.EditorInfo -import android.widget.TextView.OnEditorActionListener import android.widget.Toast import androidx.core.widget.doOnTextChanged import androidx.lifecycle.LifecycleOwner @@ -32,6 +30,7 @@ import com.celzero.bravedns.databinding.DialogWgAddPeerBinding import com.celzero.bravedns.service.WireguardManager import com.celzero.bravedns.util.UIUtils.getDurationInHumanReadableFormat import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.tos import com.celzero.bravedns.wireguard.Peer import com.celzero.bravedns.wireguard.util.ErrorMessages import kotlinx.coroutines.Dispatchers @@ -67,9 +66,9 @@ class WgAddPeerDialog( if (wgPeer != null) { isEditing = true - b.peerPublicKey.setText(wgPeer.getPublicKey().base64()) + b.peerPublicKey.setText(wgPeer.getPublicKey().base64().tos()) if (wgPeer.getPreSharedKey().isPresent) { - b.peerPresharedKey.setText(wgPeer.getPreSharedKey().get().base64()) + b.peerPresharedKey.setText(wgPeer.getPreSharedKey().get().base64().tos()) } b.peerAllowedIps.setText(wgPeer.getAllowedIps().joinToString { it.toString() }) if (wgPeer.getEndpoint().isPresent) { diff --git a/app/src/full/java/com/celzero/bravedns/ui/dialog/WgHopDialog.kt b/app/src/full/java/com/celzero/bravedns/ui/dialog/WgHopDialog.kt new file mode 100644 index 000000000..6b225fd4f --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/ui/dialog/WgHopDialog.kt @@ -0,0 +1,100 @@ +/* + * Copyright 2025 RethinkDNS and its authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.celzero.bravedns.ui.dialog + +import Logger +import Logger.LOG_TAG_UI +import android.app.Activity +import android.app.Dialog +import android.os.Bundle +import android.view.Window +import android.view.WindowManager +import android.view.animation.Animation +import android.view.animation.RotateAnimation +import androidx.recyclerview.widget.LinearLayoutManager +import com.celzero.bravedns.adapter.WgHopAdapter +import com.celzero.bravedns.databinding.DialogWgHopBinding +import com.celzero.bravedns.wireguard.Config +import org.koin.core.component.KoinComponent + +class WgHopDialog( + private var activity: Activity, + themeID: Int, + private val srcId: Int, + private val hopables: List, + private val selectedId: Int +) : Dialog(activity, themeID), KoinComponent { + + private lateinit var b: DialogWgHopBinding + private lateinit var animation: Animation + private lateinit var adapter: WgHopAdapter + + companion object { + private const val ANIMATION_DURATION = 750L + private const val ANIMATION_REPEAT_COUNT = -1 + private const val ANIMATION_PIVOT_VALUE = 0.5f + private const val ANIMATION_START_DEGREE = 0.0f + private const val ANIMATION_END_DEGREE = 360.0f + + private const val TAG = "HopDlg" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + requestWindowFeature(Window.FEATURE_NO_TITLE) + b = DialogWgHopBinding.inflate(layoutInflater) + setContentView(b.root) + setCancelable(false) + addAnimation() + init() + setupClickListeners() + } + + private fun addAnimation() { + animation = + RotateAnimation( + ANIMATION_START_DEGREE, + ANIMATION_END_DEGREE, + Animation.RELATIVE_TO_SELF, + ANIMATION_PIVOT_VALUE, + Animation.RELATIVE_TO_SELF, + ANIMATION_PIVOT_VALUE + ) + animation.repeatCount = ANIMATION_REPEAT_COUNT + animation.duration = ANIMATION_DURATION + } + + private fun init() { + window?.setLayout( + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.MATCH_PARENT + ) + Logger.v(LOG_TAG_UI, "$TAG; init called") + + val layoutManager = LinearLayoutManager(activity) + b.wgHopRecyclerView.layoutManager = layoutManager + adapter = WgHopAdapter(activity, srcId, hopables, selectedId) + b.wgHopRecyclerView.adapter = adapter + } + + private fun setupClickListeners() { + b.wgHopDialogOkButton.setOnClickListener { + Logger.d(LOG_TAG_UI, "$TAG; dismiss hop dialog") + dismiss() + } + } +} diff --git a/app/src/full/java/com/celzero/bravedns/ui/dialog/WgIncludeAppsDialog.kt b/app/src/full/java/com/celzero/bravedns/ui/dialog/WgIncludeAppsDialog.kt index 6f9f6a0d4..de230c3b4 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/dialog/WgIncludeAppsDialog.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/dialog/WgIncludeAppsDialog.kt @@ -36,7 +36,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.celzero.bravedns.R import com.celzero.bravedns.database.RefreshDatabase -import com.celzero.bravedns.databinding.WgAppsIncludeDialogBinding +import com.celzero.bravedns.databinding.DialogWgAppsBinding import com.celzero.bravedns.service.ProxyManager import com.celzero.bravedns.util.Utilities import com.celzero.bravedns.viewmodel.ProxyAppsMappingViewModel @@ -56,7 +56,7 @@ class WgIncludeAppsDialog( private val proxyName: String ) : Dialog(activity, themeID), SearchView.OnQueryTextListener, KoinComponent { - private lateinit var b: WgAppsIncludeDialogBinding + private lateinit var b: DialogWgAppsBinding private lateinit var animation: Animation private val refreshDatabase by inject() @@ -91,7 +91,7 @@ class WgIncludeAppsDialog( super.onCreate(savedInstanceState) requestWindowFeature(Window.FEATURE_NO_TITLE) - b = WgAppsIncludeDialogBinding.inflate(layoutInflater) + b = DialogWgAppsBinding.inflate(layoutInflater) setContentView(b.root) setCancelable(false) addAnimation() diff --git a/app/src/full/java/com/celzero/bravedns/ui/fragment/AboutFragment.kt b/app/src/full/java/com/celzero/bravedns/ui/fragment/AboutFragment.kt index bcb3c3f89..b56a334c8 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/fragment/AboutFragment.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/fragment/AboutFragment.kt @@ -22,14 +22,13 @@ import android.content.DialogInterface import android.content.Intent import android.content.pm.PackageInfo import android.content.pm.PackageManager -import android.icu.lang.UCharacter.GraphemeClusterBreak.T import android.net.Uri import android.os.Bundle -import android.os.Parcelable import android.os.SystemClock import android.provider.Settings import android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS import android.text.method.LinkMovementMethod +import android.view.Gravity import android.view.LayoutInflater import android.view.View import android.view.WindowManager @@ -48,16 +47,20 @@ import com.celzero.bravedns.databinding.DialogInfoRulesLayoutBinding import com.celzero.bravedns.databinding.DialogViewLogsBinding import com.celzero.bravedns.databinding.DialogWhatsnewBinding import com.celzero.bravedns.databinding.FragmentAboutBinding +import com.celzero.bravedns.rpnproxy.RpnProxyManager import com.celzero.bravedns.scheduler.BugReportZipper.FILE_PROVIDER_NAME import com.celzero.bravedns.scheduler.BugReportZipper.getZipFileName import com.celzero.bravedns.scheduler.EnhancedBugReport import com.celzero.bravedns.scheduler.WorkScheduler import com.celzero.bravedns.service.AppUpdater +import com.celzero.bravedns.service.PersistentState import com.celzero.bravedns.service.VpnController import com.celzero.bravedns.ui.HomeScreenActivity import com.celzero.bravedns.util.Constants.Companion.INIT_TIME_MS import com.celzero.bravedns.util.Constants.Companion.RETHINKDNS_SPONSOR_LINK +import com.celzero.bravedns.util.UIUtils import com.celzero.bravedns.util.UIUtils.openAppInfo +import com.celzero.bravedns.util.UIUtils.openUrl import com.celzero.bravedns.util.UIUtils.openVpnProfile import com.celzero.bravedns.util.UIUtils.sendEmailIntent import com.celzero.bravedns.util.UIUtils.updateHtmlEncodedText @@ -73,11 +76,8 @@ import kotlinx.coroutines.withContext import org.koin.android.ext.android.inject import org.koin.core.component.KoinComponent import java.io.File -import java.io.FileInputStream import java.util.concurrent.TimeUnit -import java.util.zip.ZipEntry import java.util.zip.ZipFile -import java.util.zip.ZipInputStream class AboutFragment : Fragment(R.layout.fragment_about), View.OnClickListener, KoinComponent { private val b by viewBinding(FragmentAboutBinding::bind) @@ -85,29 +85,40 @@ class AboutFragment : Fragment(R.layout.fragment_about), View.OnClickListener, K private var lastAppExitInfoDialogInvokeTime = INIT_TIME_MS private val workScheduler by inject() + companion object { + private const val SCHEME_PACKAGE = "package" + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) initView() } private fun initView() { - if (isFdroidFlavour()) { b.aboutAppUpdate.visibility = View.GONE } + updateVersionInfo() + + updateSponsorInfo() + b.aboutSponsor.setOnClickListener(this) b.aboutWebsite.setOnClickListener(this) b.aboutTwitter.setOnClickListener(this) b.aboutGithub.setOnClickListener(this) b.aboutBlog.setOnClickListener(this) b.aboutPrivacyPolicy.setOnClickListener(this) + b.aboutTermsOfService.setOnClickListener(this) + b.aboutLicense.setOnClickListener(this) b.aboutMail.setOnClickListener(this) b.aboutTelegram.setOnClickListener(this) + b.aboutReddit.setOnClickListener(this) + b.aboutMastodon.setOnClickListener(this) + b.aboutElement.setOnClickListener(this) b.aboutFaq.setOnClickListener(this) b.mozillaImg.setOnClickListener(this) b.fossImg.setOnClickListener(this) - b.osomImg.setOnClickListener(this) b.aboutAppUpdate.setOnClickListener(this) b.aboutWhatsNew.setOnClickListener(this) b.aboutAppInfo.setOnClickListener(this) @@ -117,19 +128,36 @@ class AboutFragment : Fragment(R.layout.fragment_about), View.OnClickListener, K b.aboutAppVersion.setOnClickListener(this) b.aboutAppContributors.setOnClickListener(this) b.aboutAppTranslate.setOnClickListener(this) + b.aboutStats.setOnClickListener(this) + } + private fun updateVersionInfo() { try { - val version = getVersionName() ?: "" + val version = getVersionName() // take first 7 characters of the version name, as the version has build number // appended to it, which is not required for the user to see. - val slicedVersion = version.slice(0..6) ?: "" + val slicedVersion = version.slice(0..6) b.aboutWhatsNew.text = getString(R.string.about_whats_new, slicedVersion) - // show the complete version name along with the source of installation - b.aboutAppVersion.text = - getString(R.string.about_version_install_source, version, getDownloadSource()) + + // complete version name along with the source of installation + val v = getString(R.string.about_version_install_source, version, getDownloadSource()) + + val build = VpnController.goBuildVersion(false) + b.aboutAppVersion.text = "$v\n$build" + } catch (e: PackageManager.NameNotFoundException) { - Logger.w(LOG_TAG_UI, "package name not found: ${e.message}", e) + Logger.w(LOG_TAG_UI, "err-version-info; pkg name not found: ${e.message}", e) + } + } + + private fun updateSponsorInfo() { + if (RpnProxyManager.isRpnEnabled()) { + b.sponsorInfoUsage.visibility = View.GONE + b.aboutSponsor.visibility = View.GONE + return } + + b.sponsorInfoUsage.text = getSponsorInfo() } private fun getVersionName(): String { @@ -141,6 +169,23 @@ class AboutFragment : Fragment(R.layout.fragment_about), View.OnClickListener, K return pInfo?.versionName ?: "" } + private fun getSponsorInfo(): String { + val installTime = requireContext().packageManager.getPackageInfo( + requireContext().packageName, + 0 + ).firstInstallTime + val timeDiff = System.currentTimeMillis() - installTime + val days = (timeDiff / (1000 * 60 * 60 * 24)).toDouble() + val month = days / 30 + val amount = month * (0.60 + 0.20) + val msg = getString( + R.string.sponser_dialog_usage_msg, + days.toInt().toString(), + "%.2f".format(amount) + ) + return msg + } + private fun getDownloadSource(): String { if (isFdroidFlavour()) return getString(R.string.build__flavor_fdroid) @@ -152,16 +197,16 @@ class AboutFragment : Fragment(R.layout.fragment_about), View.OnClickListener, K override fun onClick(view: View?) { when (view) { b.aboutTelegram -> { - openActionViewIntent(getString(R.string.about_telegram_link).toUri()) + openUrl(requireContext(), getString(R.string.about_telegram_link)) } b.aboutBlog -> { - openActionViewIntent(getString(R.string.about_docs_link).toUri()) + openUrl(requireContext(), getString(R.string.about_docs_link)) } b.aboutFaq -> { - openActionViewIntent(getString(R.string.about_faq_link).toUri()) + openUrl(requireContext(), getString(R.string.about_faq_link)) } b.aboutGithub -> { - openActionViewIntent(getString(R.string.about_github_link).toUri()) + openUrl(requireContext(), getString(R.string.about_github_link)) } b.aboutCrashLog -> { if (isAtleastO()) { @@ -174,22 +219,19 @@ class AboutFragment : Fragment(R.layout.fragment_about), View.OnClickListener, K sendEmailIntent(requireContext()) } b.aboutTwitter -> { - openActionViewIntent(getString(R.string.about_twitter_handle).toUri()) + openUrl(requireContext(), getString(R.string.about_twitter_handle)) } b.aboutWebsite -> { - openActionViewIntent(getString(R.string.about_website_link).toUri()) + openUrl(requireContext(), getString(R.string.about_website_link)) } b.aboutSponsor -> { - openActionViewIntent(RETHINKDNS_SPONSOR_LINK.toUri()) + openUrl(requireContext(), RETHINKDNS_SPONSOR_LINK) } b.mozillaImg -> { - openActionViewIntent(getString(R.string.about_mozilla_alumni_link).toUri()) + openUrl(requireContext(), getString(R.string.about_mozilla_alumni_link)) } b.fossImg -> { - openActionViewIntent(getString(R.string.about_foss_link).toUri()) - } - b.osomImg -> { - openActionViewIntent(getString(R.string.about_osom_link).toUri()) + openUrl(requireContext(), getString(R.string.about_foss_link)) } b.aboutAppUpdate -> { (requireContext() as HomeScreenActivity).checkForUpdate( @@ -212,10 +254,74 @@ class AboutFragment : Fragment(R.layout.fragment_about), View.OnClickListener, K showContributors() } b.aboutAppTranslate -> { - openActionViewIntent(getString(R.string.about_translate_link).toUri()) + openUrl(requireContext(), getString(R.string.about_translate_link)) } b.aboutPrivacyPolicy -> { - openActionViewIntent(getString(R.string.about_privacy_policy_link).toUri()) + openUrl(requireContext(), getString(R.string.about_privacy_policy_link)) + } + b.aboutTermsOfService -> { + openUrl(requireContext(), getString(R.string.about_terms_link)) + } + b.aboutLicense -> { + openUrl(requireContext(), getString(R.string.about_license_link)) + } + b.aboutReddit -> { + openUrl(requireContext(), getString(R.string.about_reddit_handle)) + } + b.aboutMastodon -> { + openUrl(requireContext(), getString(R.string.about_mastodom_handle)) + } + b.aboutElement -> { + openUrl(requireContext(), getString(R.string.about_matrix_handle)) + } + b.aboutStats -> { + openStatsDialog() + } + } + } + + private fun openStatsDialog() { + io { + val stat = VpnController.getNetStat() + val formatedStat = UIUtils.formatNetStat(stat) + val vpnStats = VpnController.vpnStats() + uiCtx { + val dialogBinding = DialogInfoRulesLayoutBinding.inflate(layoutInflater) + val builder = + MaterialAlertDialogBuilder(requireContext()).setView(dialogBinding.root) + val lp = WindowManager.LayoutParams() + val dialog = builder.create() + dialog.show() + lp.copyFrom(dialog.window?.attributes) + lp.width = WindowManager.LayoutParams.MATCH_PARENT + lp.height = WindowManager.LayoutParams.WRAP_CONTENT + + dialog.setCancelable(true) + dialog.window?.attributes = lp + + val heading = dialogBinding.infoRulesDialogRulesTitle + val okBtn = dialogBinding.infoRulesDialogCancelImg + val descText = dialogBinding.infoRulesDialogRulesDesc + dialogBinding.infoRulesDialogRulesIcon.visibility = View.GONE + + heading.text = getString(R.string.stats_title) + heading.setCompoundDrawablesWithIntrinsicBounds( + ContextCompat.getDrawable(requireContext(), R.drawable.ic_log_level), + null, + null, + null + ) + + descText.movementMethod = LinkMovementMethod.getInstance() + if (formatedStat == null) { + descText.text = "Stats not available" + } else { + descText.text = formatedStat + vpnStats + } + + okBtn.setOnClickListener { dialog.dismiss() } + + dialog.show() } } } @@ -232,20 +338,6 @@ class AboutFragment : Fragment(R.layout.fragment_about), View.OnClickListener, K builder.create().show() } - private fun openActionViewIntent(uri: Uri) { - val intent = Intent(Intent.ACTION_VIEW, uri) - try { - startActivity(intent) - } catch (e: ActivityNotFoundException) { - showToastUiCentered( - requireContext(), - getString(R.string.intent_launch_error, intent.data), - Toast.LENGTH_SHORT - ) - Logger.w(LOG_TAG_UI, "activity not found ${e.message}", e) - } - } - private fun openNotificationSettings() { val packageName = requireContext().packageName try { @@ -256,7 +348,7 @@ class AboutFragment : Fragment(R.layout.fragment_about), View.OnClickListener, K } else { intent.action = ACTION_APPLICATION_DETAILS_SETTINGS intent.addCategory(Intent.CATEGORY_DEFAULT) - intent.data = Uri.parse("package:$packageName") + intent.data = "$SCHEME_PACKAGE:$packageName".toUri() } startActivity(intent) } catch (e: ActivityNotFoundException) { @@ -299,12 +391,14 @@ class AboutFragment : Fragment(R.layout.fragment_about), View.OnClickListener, K private fun emailBugReport() { try { // get the rethink.tombstone file - val tombstoneFile = EnhancedBugReport.getTombstoneZipFile(requireContext()) - // Get the bug_report.zip file + val tombstoneFile:File? = EnhancedBugReport.getTombstoneZipFile(requireContext()) + + // get the bug_report.zip file val dir = requireContext().filesDir val file = File(getZipFileName(dir)) - val uri = getFileUri(file) + val uri = getFileUri(file) ?: throw Exception("file uri is null") + // create an intent for sending email with or without multiple attachments val emailIntent = if (tombstoneFile != null) { Intent(Intent.ACTION_SEND_MULTIPLE) } else { @@ -316,14 +410,19 @@ class AboutFragment : Fragment(R.layout.fragment_about), View.OnClickListener, K Intent.EXTRA_SUBJECT, getString(R.string.about_mail_bugreport_subject) ) - emailIntent.putExtra(Intent.EXTRA_TEXT, getString(R.string.about_mail_bugreport_text)) - // attach extra as list or single file based on the availability + // attach extra files (either as a list or single file based on availability) if (tombstoneFile != null) { - val tombstoneUri = getFileUri(tombstoneFile) + val tombstoneUri = + getFileUri(tombstoneFile) ?: throw Exception("tombstoneUri is null") + val uriList = arrayListOf(uri, tombstoneUri) // send multiple attachments - emailIntent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, arrayListOf(uri, tombstoneUri)) + emailIntent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uriList) } else { + // ensure EXTRA_TEXT is passed correctly as an ArrayList + val bugReportText = getString(R.string.about_mail_bugreport_text) + val bugReportTextList = arrayListOf(bugReportText) + emailIntent.putCharSequenceArrayListExtra(Intent.EXTRA_TEXT, bugReportTextList) emailIntent.putExtra(Intent.EXTRA_STREAM, uri) } Logger.i(LOG_TAG_UI, "email with attachment: $uri, ${tombstoneFile?.path}") @@ -386,6 +485,9 @@ class AboutFragment : Fragment(R.layout.fragment_about), View.OnClickListener, K null ) + heading.gravity = Gravity.CENTER + descText.gravity = Gravity.CENTER + descText.movementMethod = LinkMovementMethod.getInstance() descText.text = updateHtmlEncodedText(getString(R.string.contributors_list)) @@ -418,26 +520,38 @@ class AboutFragment : Fragment(R.layout.fragment_about), View.OnClickListener, K } io { - var fin: FileInputStream? = null - var zin: ZipInputStream? = null // load only 20k characters to avoid ANR val maxLength = 20000 try { - fin = FileInputStream(zipPath) - zin = ZipInputStream(fin) - var ze: ZipEntry? - var inputString: String? = "" - // don't load more than 20k characters to avoid ANR - // TODO: use recycler view instead of textview - while (zin.nextEntry.also { ze = it } != null) { - val inStream = zipFile.getInputStream(ze) - inputString += inStream?.bufferedReader().use { it?.readText() } - if (inputString?.length!! > maxLength) break + val inputString = StringBuilder(maxLength) + val entries = zipFile.entries() + val buffer = CharArray(4096) // Read in smaller chunks + var shouldBreak = false + while (entries.hasMoreElements() && !shouldBreak) { + val entry = entries.nextElement() + zipFile.getInputStream(entry).use { inputStream -> + val reader = inputStream.bufferedReader() + var charsRead: Int + while (reader.read(buffer).also { charsRead = it } > 0 && !shouldBreak) { + if (charsRead + inputString.length > maxLength) { + // add only what we need to reach maxLength + inputString.append(buffer, 0, maxLength - inputString.length) + shouldBreak = true + break + } else { + inputString.append(buffer, 0, charsRead) + } + } + } + if (inputString.length >= maxLength) { + break + } } + Logger.d(LOG_TAG_UI, "bug report content size: ${inputString.length}, $zipPath, ${zipFile.size()}") uiCtx { if (!isAdded) return@uiCtx binding.info.visibility = View.VISIBLE - if (inputString == null) { + if (inputString.isEmpty()) { binding.logs.text = getString(R.string.error_loading_log_file) return@uiCtx } @@ -458,8 +572,6 @@ class AboutFragment : Fragment(R.layout.fragment_about), View.OnClickListener, K binding.logs.text = getString(R.string.error_loading_log_file) } } finally { - fin?.close() - zin?.close() zipFile.close() } diff --git a/app/src/full/java/com/celzero/bravedns/ui/fragment/ConfigureFragment.kt b/app/src/full/java/com/celzero/bravedns/ui/fragment/ConfigureFragment.kt index 5592dcd34..c4c26bb02 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/fragment/ConfigureFragment.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/fragment/ConfigureFragment.kt @@ -23,6 +23,7 @@ import by.kirich1409.viewbindingdelegate.viewBinding import com.celzero.bravedns.R import com.celzero.bravedns.databinding.FragmentConfigureBinding import com.celzero.bravedns.ui.activity.AppListActivity +import com.celzero.bravedns.ui.activity.AdvancedSettingActivity import com.celzero.bravedns.ui.activity.DnsDetailActivity import com.celzero.bravedns.ui.activity.FirewallActivity import com.celzero.bravedns.ui.activity.MiscSettingsActivity @@ -41,18 +42,23 @@ class ConfigureFragment : Fragment(R.layout.fragment_configure) { PROXY, VPN, OTHERS, - LOGS + LOGS, + ADVANCED } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) initView() + setupClickListeners() } private fun initView() { b.fsNetworkTv.text = getString(R.string.lbl_network).replaceFirstChar(Char::titlecase) b.fsLogsTv.text = getString(R.string.lbl_logs).replaceFirstChar(Char::titlecase) + b.fsAdvancedTv.text = getString(R.string.lbl_advanced).replaceFirstChar(Char::titlecase) + } + private fun setupClickListeners() { b.fsAppsCard.setOnClickListener { // open apps configuration startActivity(ScreenType.APPS) @@ -87,6 +93,11 @@ class ConfigureFragment : Fragment(R.layout.fragment_configure) { // open logs configuration startActivity(ScreenType.LOGS) } + + b.fsAdvancedCard.setOnClickListener { + // open developer options configuration + startActivity(ScreenType.ADVANCED) + } } private fun startActivity(type: ScreenType) { @@ -99,8 +110,8 @@ class ConfigureFragment : Fragment(R.layout.fragment_configure) { ScreenType.VPN -> Intent(requireContext(), TunnelSettingsActivity::class.java) ScreenType.OTHERS -> Intent(requireContext(), MiscSettingsActivity::class.java) ScreenType.LOGS -> Intent(requireContext(), NetworkLogsActivity::class.java) + ScreenType.ADVANCED -> Intent(requireContext(), AdvancedSettingActivity::class.java) } - intent.flags = Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED startActivity(intent) } } diff --git a/app/src/full/java/com/celzero/bravedns/ui/fragment/ConnectionTrackerFragment.kt b/app/src/full/java/com/celzero/bravedns/ui/fragment/ConnectionTrackerFragment.kt index 3d1d234b7..38b7fc5a7 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/fragment/ConnectionTrackerFragment.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/fragment/ConnectionTrackerFragment.kt @@ -15,25 +15,29 @@ limitations under the License. */ package com.celzero.bravedns.ui.fragment +import Logger +import Logger.LOG_TAG_UI +import android.content.Context.INPUT_METHOD_SERVICE import android.os.Bundle import android.view.View +import android.view.inputmethod.InputMethodManager import android.widget.CompoundButton import androidx.appcompat.widget.SearchView import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.fragment.app.Fragment -import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import by.kirich1409.viewbindingdelegate.viewBinding import com.celzero.bravedns.R import com.celzero.bravedns.adapter.ConnectionTrackerAdapter import com.celzero.bravedns.database.ConnectionTrackerRepository -import com.celzero.bravedns.databinding.ActivityConnectionTrackerBinding +import com.celzero.bravedns.databinding.FragmentConnectionTrackerBinding import com.celzero.bravedns.service.FirewallRuleset import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.ui.activity.NetworkLogsActivity +import com.celzero.bravedns.ui.activity.UniversalFirewallSettingsActivity import com.celzero.bravedns.util.Constants import com.celzero.bravedns.util.UIUtils.formatToRelativeTime import com.celzero.bravedns.util.Utilities @@ -48,8 +52,8 @@ import org.koin.androidx.viewmodel.ext.android.viewModel /** Captures network logs and stores in ConnectionTracker, a room database. */ class ConnectionTrackerFragment : - Fragment(R.layout.activity_connection_tracker), SearchView.OnQueryTextListener { - private val b by viewBinding(ActivityConnectionTrackerBinding::bind) + Fragment(R.layout.fragment_connection_tracker), SearchView.OnQueryTextListener { + private val b by viewBinding(FragmentConnectionTrackerBinding::bind) private var layoutManager: RecyclerView.LayoutManager? = null private val viewModel: ConnectionTrackerViewModel by viewModel() @@ -60,8 +64,13 @@ class ConnectionTrackerFragment : private val connectionTrackerRepository by inject() private val persistentState by inject() + private var fromWireGuardScreen: Boolean = false + private var fromUniversalFirewallScreen: Boolean = false + companion object { + private const val TAG = "ConnTrackFrag" const val PROTOCOL_FILTER_PREFIX = "P:" + private const val QUERY_TEXT_DELAY: Long = 1000 fun newInstance(param: String): ConnectionTrackerFragment { val args = Bundle() @@ -74,15 +83,31 @@ class ConnectionTrackerFragment : override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - initView() if (arguments != null) { val query = arguments?.getString(Constants.SEARCH_QUERY) ?: return - b.connectionSearch.setQuery(query, true) + fromUniversalFirewallScreen = query.contains(UniversalFirewallSettingsActivity.RULES_SEARCH_ID) + fromWireGuardScreen = query.contains(NetworkLogsActivity.RULES_SEARCH_ID_WIREGUARD) + if (fromUniversalFirewallScreen) { + val rule = query.split(UniversalFirewallSettingsActivity.RULES_SEARCH_ID)[1] + filterCategories.add(rule) + filterType = TopLevelFilter.BLOCKED + viewModel.setFilter(filterQuery, filterCategories, filterType) + hideSearchLayout() + } else if (fromWireGuardScreen) { + val rule = query.split(NetworkLogsActivity.RULES_SEARCH_ID_WIREGUARD)[1] + filterQuery = rule + filterType = TopLevelFilter.ALL + viewModel.setFilter(filterQuery, filterCategories, filterType) + hideSearchLayout() + } else { + b.connectionSearch.setQuery(query, true) + } } + initView() + Logger.v(LOG_TAG_UI, "$TAG, view created from univ? $fromUniversalFirewallScreen, from wg? $fromWireGuardScreen") } private fun initView() { - if (!persistentState.logsEnabled) { b.connectionListLogsDisabledTv.visibility = View.VISIBLE b.connectionCardViewTop.visibility = View.GONE @@ -90,22 +115,14 @@ class ConnectionTrackerFragment : } b.connectionListLogsDisabledTv.visibility = View.GONE - b.connectionCardViewTop.visibility = View.VISIBLE - b.recyclerConnection.setHasFixedSize(true) - layoutManager = LinearLayoutManager(requireContext()) - b.recyclerConnection.layoutManager = layoutManager - val recyclerAdapter = ConnectionTrackerAdapter(requireContext()) - viewLifecycleOwner.lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.connectionTrackerList.observe(viewLifecycleOwner) { it -> - recyclerAdapter.submitData(lifecycle, it) - } - } + if (fromWireGuardScreen || fromUniversalFirewallScreen) { + hideSearchLayout() + } else { + b.connectionCardViewTop.visibility = View.VISIBLE } - b.recyclerConnection.adapter = recyclerAdapter - setupRecyclerScrollListener() + setupRecyclerView() b.connectionSearch.setOnQueryTextListener(this) b.connectionSearch.setOnClickListener { @@ -123,8 +140,70 @@ class ConnectionTrackerFragment : remakeChildFilterChipsUi(FirewallRuleset.getBlockedRules()) } + private fun setupRecyclerView() { + b.recyclerConnection.setHasFixedSize(true) + layoutManager = LinearLayoutManager(requireContext()) + layoutManager?.isItemPrefetchEnabled = true + b.recyclerConnection.layoutManager = layoutManager + + val recyclerAdapter = ConnectionTrackerAdapter(requireContext()) + recyclerAdapter.stateRestorationPolicy = + RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY + + b.recyclerConnection.adapter = recyclerAdapter + + viewModel.connectionTrackerList.observe(viewLifecycleOwner) { pagingData -> + recyclerAdapter.submitData(lifecycle, pagingData) + } + + recyclerAdapter.addLoadStateListener { loadState -> + val isEmpty = recyclerAdapter.itemCount < 1 + if (loadState.append.endOfPaginationReached && isEmpty) { + if (fromUniversalFirewallScreen || fromWireGuardScreen) { + b.connectionListLogsDisabledTv.text = getString(R.string.ada_ip_no_connection) + b.connectionListLogsDisabledTv.visibility = View.VISIBLE + b.connectionCardViewTop.visibility = View.GONE + } else { + b.connectionListLogsDisabledTv.visibility = View.GONE + b.connectionCardViewTop.visibility = View.VISIBLE + } + viewModel.connectionTrackerList.removeObservers(this) + } else { + b.connectionListLogsDisabledTv.visibility = View.GONE + b.connectionCardViewTop.visibility = View.VISIBLE + } + } + + b.recyclerConnection.post { + try { + if (recyclerAdapter.itemCount > 0) { + recyclerAdapter.stateRestorationPolicy = + RecyclerView.Adapter.StateRestorationPolicy.ALLOW + } + } catch (ignored: Exception) { + Logger.e(LOG_TAG_UI, "$TAG; err in setting the recycler restoration policy") + } + } + b.recyclerConnection.layoutAnimation = null + setupRecyclerScrollListener() + } + + + private fun hideSearchLayout() { + b.connectionCardViewTop.visibility = View.GONE + } + override fun onResume() { super.onResume() + // fix for #1939, OEM-specific bug, especially on heavily customized Android + // some ROMs kill or freeze the keyboard/IME process to save memory or battery, + // causing SearchView to stop receiving input events + // this is a workaround to restart the IME process + b.connectionSearch.setQuery("", false) + b.connectionSearch.clearFocus() + + val imm = requireContext().getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager + imm.restartInput(b.connectionSearch) b.connectionListRl.requestFocus() } @@ -135,9 +214,17 @@ class ConnectionTrackerFragment : override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { super.onScrolled(recyclerView, dx, dy) - if (recyclerView.getChildAt(0)?.tag == null) return + val firstChild = recyclerView.getChildAt(0) + if (firstChild == null) { + Logger.v(LOG_TAG_UI, "$TAG; err; no child views found in recyclerView") + return + } - val tag: Long = recyclerView.getChildAt(0).tag as Long + val tag = firstChild.tag as? Long + if (tag == null) { + Logger.v(LOG_TAG_UI, "$TAG; err; tag is null for first child, rv") + return + } b.connectionListScrollHeader.text = formatToRelativeTime(requireContext(), tag) b.connectionListScrollHeader.visibility = View.VISIBLE @@ -247,13 +334,17 @@ class ConnectionTrackerFragment : } override fun onQueryTextSubmit(query: String): Boolean { - this.filterQuery = query - viewModel.setFilter(query, filterCategories, filterType) + Utilities.delay(QUERY_TEXT_DELAY, lifecycleScope) { + if (this.isAdded) { + this.filterQuery = query + viewModel.setFilter(query, filterCategories, filterType) + } + } return true } override fun onQueryTextChange(query: String): Boolean { - Utilities.delay(500, lifecycleScope) { + Utilities.delay(QUERY_TEXT_DELAY, lifecycleScope) { if (this.isAdded) { this.filterQuery = query viewModel.setFilter(query, filterCategories, filterType) @@ -320,7 +411,6 @@ class ConnectionTrackerFragment : b.filterChipParentGroup.visibility = View.GONE } - // fixme: move this to viewmodel scope private fun io(f: suspend () -> Unit) { lifecycleScope.launch(Dispatchers.IO) { f() } } diff --git a/app/src/full/java/com/celzero/bravedns/ui/fragment/CustomDomainFragment.kt b/app/src/full/java/com/celzero/bravedns/ui/fragment/CustomDomainFragment.kt index 5576ed3b0..54348eaf1 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/fragment/CustomDomainFragment.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/fragment/CustomDomainFragment.kt @@ -15,9 +15,11 @@ */ package com.celzero.bravedns.ui.fragment +import android.content.Context.INPUT_METHOD_SERVICE import android.os.Bundle import android.view.View import android.view.WindowManager +import android.view.inputmethod.InputMethodManager import android.widget.Toast import androidx.appcompat.widget.SearchView import androidx.core.widget.addTextChangedListener @@ -34,6 +36,7 @@ import com.celzero.bravedns.databinding.FragmentCustomDomainBinding import com.celzero.bravedns.service.DomainRulesManager import com.celzero.bravedns.service.DomainRulesManager.isValidDomain import com.celzero.bravedns.service.DomainRulesManager.isWildCardEntry +import com.celzero.bravedns.service.FirewallManager import com.celzero.bravedns.ui.activity.CustomRulesActivity import com.celzero.bravedns.util.Constants.Companion.INTENT_UID import com.celzero.bravedns.util.Constants.Companion.UID_EVERYBODY @@ -43,13 +46,16 @@ import com.celzero.bravedns.viewmodel.CustomDomainViewModel import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.koin.android.ext.android.inject +import java.net.URI class CustomDomainFragment : Fragment(R.layout.fragment_custom_domain), SearchView.OnQueryTextListener { private val b by viewBinding(FragmentCustomDomainBinding::bind) private var layoutManager: RecyclerView.LayoutManager? = null + private lateinit var adapter: CustomDomainAdapter private val viewModel by inject() @@ -72,6 +78,19 @@ class CustomDomainFragment : initView() } + override fun onResume() { + super.onResume() + // fix for #1939, OEM-specific bug, especially on heavily customized Android + // some ROMs kill or freeze the keyboard/IME process to save memory or battery, + // causing SearchView to stop receiving input events + // this is a workaround to restart the IME process + b.cdaSearchView.setQuery("", false) + b.cdaSearchView.clearFocus() + + val imm = requireContext().getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager + imm.restartInput(b.cdaSearchView) + } + private fun initView() { uid = arguments?.getInt(INTENT_UID, UID_EVERYBODY) ?: UID_EVERYBODY rule = @@ -101,17 +120,36 @@ class CustomDomainFragment : private fun setupAppSpecificRules(rule: CustomRulesActivity.RULES) { observeCustomRules() - val adapter = CustomDomainAdapter(requireContext(), rule) + adapter = CustomDomainAdapter(requireContext(), this, rule) b.cdaRecycler.adapter = adapter viewModel.setUid(uid) viewModel.customDomains.observe(this as LifecycleOwner) { adapter.submitData(this.lifecycle, it) } + io { + val appName = FirewallManager.getAppNameByUid(uid) + if (appName != null) { + uiCtx { updateAppNameInSearchHint(appName) } + } + } + } + + private fun updateAppNameInSearchHint(appName: String) { + val appNameTruncated = appName.substring(0, appName.length.coerceAtMost(10)) + val hint = getString( + R.string.two_argument_colon, + appNameTruncated, + getString(R.string.search_custom_domains) + ) + b.cdaSearchView.queryHint = hint + b.cdaSearchView.findViewById(androidx.appcompat.R.id.search_src_text).textSize = + 14f + return } private fun setupAllRules(rule: CustomRulesActivity.RULES) { observeAllRules() - val adapter = CustomDomainAdapter(requireContext(), rule) + adapter = CustomDomainAdapter(requireContext(), this, rule) b.cdaRecycler.adapter = adapter viewModel.allDomainRules.observe(this as LifecycleOwner) { adapter.submitData(this.lifecycle, it) @@ -189,8 +227,10 @@ class CustomDomainFragment : var selectedType: DomainRulesManager.DomainType = DomainRulesManager.DomainType.DOMAIN dBind.dacdDomainEditText.addTextChangedListener { - if (it?.contains("*") == true) { + if (it?.startsWith("*") == true || it?.startsWith(".") == true) { dBind.dacdWildcardChip.isChecked = true + } else { + dBind.dacdDomainChip.isChecked = true } } @@ -251,9 +291,15 @@ class CustomDomainFragment : ) { dBind.dacdFailureText.visibility = View.GONE val url = dBind.dacdDomainEditText.text.toString() + val extractedHost = extractHost(url) ?: run { + dBind.dacdFailureText.text = + getString(R.string.cd_dialog_error_invalid_domain) + dBind.dacdFailureText.visibility = View.VISIBLE + return + } when (selectedType) { DomainRulesManager.DomainType.WILDCARD -> { - if (!isWildCardEntry(url)) { + if (!isWildCardEntry(extractedHost)) { dBind.dacdFailureText.text = getString(R.string.cd_dialog_error_invalid_wildcard) dBind.dacdFailureText.visibility = View.VISIBLE @@ -261,7 +307,7 @@ class CustomDomainFragment : } } DomainRulesManager.DomainType.DOMAIN -> { - if (!isValidDomain(url)) { + if (!isValidDomain(extractedHost)) { dBind.dacdFailureText.text = getString(R.string.cd_dialog_error_invalid_domain) dBind.dacdFailureText.visibility = View.VISIBLE return @@ -269,9 +315,44 @@ class CustomDomainFragment : } } - insertDomain(removeLeadingAndTrailingDots(url), selectedType, status) + insertDomain(removeLeadingAndTrailingDots(extractedHost), selectedType, status) } + private fun extractHost(input: String): String? { + val trimmedInput = input.trim() + + return when { + // case: valid wildcard input without schema, eg., *.example.com + trimmedInput.startsWith("*.") && !trimmedInput.contains("://") -> { + trimmedInput + } + + // case: invalid wildcard with schema, eg., https://*.example.com + trimmedInput.contains("://") && trimmedInput.contains("*") -> { + null // Invalid: Wildcards shouldn't appear in URLs + } + + // case: standard URL input, eg., https://www.example.com + trimmedInput.contains("://") -> { + try { + // return the host part of the URL + // only www. is the common prefix you'd want to strip for cosmetic or + // standardization reasons (like www.google.com → google.com). Other subdomains + // (e.g., mail., api., m.) are actually part of the valid hostname and + // should not be removed + val uri = URI(trimmedInput) + uri.host?.removePrefix("www.") // remove 'www.' prefix if present + } catch (e: Exception) { + null + } + } + + // case: plain domain (no schema, no wildcard), eg., example.com + else -> trimmedInput + } + } + + private fun insertDomain( domain: String, type: DomainRulesManager.DomainType, @@ -300,11 +381,18 @@ class CustomDomainFragment : builder.setTitle(R.string.univ_delete_firewall_dialog_title) builder.setMessage(R.string.univ_delete_firewall_dialog_message) builder.setPositiveButton(getString(R.string.univ_ip_delete_dialog_positive)) { _, _ -> + io { - if (rule == CustomRulesActivity.RULES.APP_SPECIFIC_RULES) { - DomainRulesManager.deleteRulesByUid(uid) + val selectedItems = adapter.getSelectedItems() + if (selectedItems.isNotEmpty()) { + uiCtx { adapter.clearSelection() } + DomainRulesManager.deleteRules(selectedItems) } else { - DomainRulesManager.deleteAllRules() + if (rule == CustomRulesActivity.RULES.APP_SPECIFIC_RULES) { + DomainRulesManager.deleteRulesByUid(uid) + } else { + DomainRulesManager.deleteAllRules() + } } } Utilities.showToastUiCentered( @@ -315,7 +403,7 @@ class CustomDomainFragment : } builder.setNegativeButton(getString(R.string.lbl_cancel)) { _, _ -> - // no-op + adapter.clearSelection() } builder.setCancelable(true) @@ -325,4 +413,8 @@ class CustomDomainFragment : private fun io(f: suspend () -> Unit) { lifecycleScope.launch(Dispatchers.IO) { f() } } + + private suspend fun uiCtx(f: suspend () -> Unit) { + withContext(Dispatchers.Main) { f() } + } } diff --git a/app/src/full/java/com/celzero/bravedns/ui/fragment/CustomIpFragment.kt b/app/src/full/java/com/celzero/bravedns/ui/fragment/CustomIpFragment.kt index d65c3f1f7..d561b6ab9 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/fragment/CustomIpFragment.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/fragment/CustomIpFragment.kt @@ -15,9 +15,11 @@ */ package com.celzero.bravedns.ui.fragment +import android.content.Context.INPUT_METHOD_SERVICE import android.os.Bundle import android.view.View import android.view.WindowManager +import android.view.inputmethod.InputMethodManager import android.widget.Toast import androidx.appcompat.widget.SearchView import androidx.core.view.isVisible @@ -30,6 +32,7 @@ import com.celzero.bravedns.R import com.celzero.bravedns.adapter.CustomIpAdapter import com.celzero.bravedns.databinding.DialogAddCustomIpBinding import com.celzero.bravedns.databinding.FragmentCustomIpBinding +import com.celzero.bravedns.service.FirewallManager import com.celzero.bravedns.service.IpRulesManager import com.celzero.bravedns.ui.activity.CustomRulesActivity import com.celzero.bravedns.util.Constants.Companion.INTENT_UID @@ -51,6 +54,7 @@ class CustomIpFragment : Fragment(R.layout.fragment_custom_ip), SearchView.OnQue private val viewModel: CustomIpViewModel by viewModel() private var uid = UID_EVERYBODY private var rules = CustomRulesActivity.RULES.APP_SPECIFIC_RULES + private lateinit var adapter: CustomIpAdapter companion object { fun newInstance(uid: Int, rules: CustomRulesActivity.RULES): CustomIpFragment { @@ -68,6 +72,19 @@ class CustomIpFragment : Fragment(R.layout.fragment_custom_ip), SearchView.OnQue initView() } + override fun onResume() { + super.onResume() + // fix for #1939, OEM-specific bug, especially on heavily customized Android + // some ROMs kill or freeze the keyboard/IME process to save memory or battery, + // causing SearchView to stop receiving input events + // this is a workaround to restart the IME process + b.cipSearchView.setQuery("", false) + b.cipSearchView.clearFocus() + + val imm = requireContext().getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager + imm.restartInput(b.cipSearchView) + } + private fun initView() { uid = arguments?.getInt(INTENT_UID, UID_EVERYBODY) ?: UID_EVERYBODY rules = @@ -140,16 +157,34 @@ class CustomIpFragment : Fragment(R.layout.fragment_custom_ip), SearchView.OnQue if (rules == CustomRulesActivity.RULES.APP_SPECIFIC_RULES) { b.cipAddFab.visibility = View.VISIBLE setupAdapterForApp() + io { + val appName = FirewallManager.getAppNameByUid(uid) + if (!appName.isNullOrEmpty()) { + uiCtx { updateAppNameInSearchHint(appName) } + } + } } else { b.cipAddFab.visibility = View.GONE setupAdapterForAllApps() } } + private fun updateAppNameInSearchHint(appName: String) { + val appNameTruncated = appName.substring(0, appName.length.coerceAtMost(10)) + val hint = getString( + R.string.two_argument_colon, + appNameTruncated, + getString(R.string.search_universal_ips) + ) + b.cipSearchView.queryHint = hint + b.cipSearchView.findViewById(androidx.appcompat.R.id.search_src_text).textSize = + 14f + return + } + private fun setupAdapterForApp() { observeAppSpecificRules() - val adapter = - CustomIpAdapter(requireContext(), CustomRulesActivity.RULES.APP_SPECIFIC_RULES) + adapter = CustomIpAdapter(requireContext(), CustomRulesActivity.RULES.APP_SPECIFIC_RULES) viewModel.setUid(uid) viewModel.customIpDetails.observe(viewLifecycleOwner) { adapter.submitData(this.lifecycle, it) @@ -159,7 +194,7 @@ class CustomIpFragment : Fragment(R.layout.fragment_custom_ip), SearchView.OnQue private fun setupAdapterForAllApps() { observeAllAppsRules() - val adapter = CustomIpAdapter(requireContext(), CustomRulesActivity.RULES.ALL_RULES) + adapter = CustomIpAdapter(requireContext(), CustomRulesActivity.RULES.ALL_RULES) viewModel.allIpRules.observe(viewLifecycleOwner) { adapter.submitData(this.lifecycle, it) } b.cipRecycler.adapter = adapter } @@ -228,7 +263,7 @@ class CustomIpFragment : Fragment(R.layout.fragment_custom_ip), SearchView.OnQue val input = dBind.daciIpEditText.text.toString() val ipString = Utilities.removeLeadingAndTrailingDots(input) var ip: IPAddress? = null - var port: Int = 0 + var port = 0 // chances of creating NetworkOnMainThread exception, handling with io operation ioCtx { @@ -251,7 +286,7 @@ class CustomIpFragment : Fragment(R.layout.fragment_custom_ip), SearchView.OnQue private fun insertCustomIp(ip: IPAddress?, port: Int?, status: IpRulesManager.IpRuleStatus) { if (ip == null) return - io { IpRulesManager.addIpRule(uid, ip, port, status) } + io { IpRulesManager.addIpRule(uid, ip, port, status, proxyId = "", proxyCC = "") } Utilities.showToastUiCentered( requireContext(), getString(R.string.ci_dialog_added_success), @@ -265,10 +300,16 @@ class CustomIpFragment : Fragment(R.layout.fragment_custom_ip), SearchView.OnQue builder.setMessage(R.string.univ_delete_firewall_dialog_message) builder.setPositiveButton(getString(R.string.univ_ip_delete_dialog_positive)) { _, _ -> io { - if (rules == CustomRulesActivity.RULES.APP_SPECIFIC_RULES) { - IpRulesManager.deleteRulesByUid(uid) + val selectedItems = adapter.getSelectedItems() + if (selectedItems.isNotEmpty()) { + IpRulesManager.deleteRules(selectedItems) + uiCtx { adapter.clearSelection() } } else { - IpRulesManager.deleteAllAppsRules() + if (rules == CustomRulesActivity.RULES.APP_SPECIFIC_RULES) { + IpRulesManager.deleteRulesByUid(uid) + } else { + IpRulesManager.deleteAllAppsRules() + } } } Utilities.showToastUiCentered( @@ -279,7 +320,7 @@ class CustomIpFragment : Fragment(R.layout.fragment_custom_ip), SearchView.OnQue } builder.setNegativeButton(getString(R.string.lbl_cancel)) { _, _ -> - // no-op + adapter.clearSelection() } builder.setCancelable(true) @@ -290,6 +331,10 @@ class CustomIpFragment : Fragment(R.layout.fragment_custom_ip), SearchView.OnQue withContext(Dispatchers.IO) { f() } } + private suspend fun uiCtx(f: suspend () -> Unit) { + withContext(Dispatchers.Main) { f() } + } + private fun io(f: suspend () -> Unit) { lifecycleScope.launch(Dispatchers.IO) { f() } } diff --git a/app/src/full/java/com/celzero/bravedns/ui/fragment/DnsCryptListFragment.kt b/app/src/full/java/com/celzero/bravedns/ui/fragment/DnsCryptListFragment.kt index 8f817d43d..b76e70f73 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/fragment/DnsCryptListFragment.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/fragment/DnsCryptListFragment.kt @@ -195,7 +195,7 @@ class DnsCryptListFragment : Fragment(R.layout.fragment_dns_crypt_list) { // Do the DNS Crypt setting there if (mode == 0) { insertDNSCryptServer(name, urlStamp, desc) - } else if (mode == 1) { + } else { insertDNSCryptRelay(name, urlStamp, desc) } dialog.dismiss() diff --git a/app/src/full/java/com/celzero/bravedns/ui/fragment/DnsLogFragment.kt b/app/src/full/java/com/celzero/bravedns/ui/fragment/DnsLogFragment.kt index bef66da2b..8c10ee430 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/fragment/DnsLogFragment.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/fragment/DnsLogFragment.kt @@ -15,24 +15,27 @@ */ package com.celzero.bravedns.ui.fragment +import Logger.LOG_TAG_UI +import android.content.Context.INPUT_METHOD_SERVICE import android.os.Bundle import android.view.View +import android.view.inputmethod.InputMethodManager import android.widget.CompoundButton import androidx.appcompat.widget.SearchView import androidx.core.view.isVisible import androidx.fragment.app.Fragment -import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import by.kirich1409.viewbindingdelegate.viewBinding import com.bumptech.glide.Glide import com.celzero.bravedns.R -import com.celzero.bravedns.adapter.DnsQueryAdapter +import com.celzero.bravedns.adapter.DnsLogAdapter +import com.celzero.bravedns.data.AppConfig import com.celzero.bravedns.database.DnsLogRepository import com.celzero.bravedns.databinding.FragmentDnsLogsBinding import com.celzero.bravedns.service.PersistentState +import com.celzero.bravedns.ui.activity.UniversalFirewallSettingsActivity import com.celzero.bravedns.util.Constants import com.celzero.bravedns.util.UIUtils.formatToRelativeTime import com.celzero.bravedns.util.Utilities @@ -56,8 +59,11 @@ class DnsLogFragment : Fragment(R.layout.fragment_dns_logs), SearchView.OnQueryT private val dnsLogRepository by inject() private val persistentState by inject() + private val appConfig by inject() companion object { + private const val QUERY_TEXT_DELAY: Long = 1000 + fun newInstance(param: String): DnsLogFragment { val args = Bundle() args.putString(Constants.SEARCH_QUERY, param) @@ -80,6 +86,10 @@ class DnsLogFragment : Fragment(R.layout.fragment_dns_logs), SearchView.OnQueryT initView() if (arguments != null) { val query = arguments?.getString(Constants.SEARCH_QUERY) ?: return + if (query.contains(UniversalFirewallSettingsActivity.RULES_SEARCH_ID)) { + // do nothing, as the search is for the firewall rules and not for the dns + return + } b.queryListSearch.setQuery(query, true) } } @@ -100,6 +110,15 @@ class DnsLogFragment : Fragment(R.layout.fragment_dns_logs), SearchView.OnQueryT override fun onResume() { super.onResume() + // fix for #1939, OEM-specific bug, especially on heavily customized Android + // some ROMs kill or freeze the keyboard/IME process to save memory or battery, + // causing SearchView to stop receiving input events + // this is a workaround to restart the IME process + b.queryListSearch.setQuery("", false) + b.queryListSearch.clearFocus() + + val imm = requireContext().getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager + imm.restartInput(b.queryListSearch) b.topRl.requestFocus() } @@ -124,12 +143,12 @@ class DnsLogFragment : Fragment(R.layout.fragment_dns_logs), SearchView.OnQueryT layoutManager = LinearLayoutManager(requireContext()) b.recyclerQuery.layoutManager = layoutManager - val recyclerAdapter = DnsQueryAdapter(requireContext(), persistentState.fetchFavIcon) + val favIcon = persistentState.fetchFavIcon + val isRethinkDns = appConfig.isRethinkDnsConnected() + val recyclerAdapter = DnsLogAdapter(requireContext(), favIcon, isRethinkDns) viewLifecycleOwner.lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.dnsLogsList.observe(viewLifecycleOwner) { - recyclerAdapter.submitData(viewLifecycleOwner.lifecycle, it) - } + viewModel.dnsLogsList.observe(viewLifecycleOwner) { + recyclerAdapter.submitData(viewLifecycleOwner.lifecycle, it) } } b.recyclerQuery.adapter = recyclerAdapter @@ -140,9 +159,17 @@ class DnsLogFragment : Fragment(R.layout.fragment_dns_logs), SearchView.OnQueryT override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { super.onScrolled(recyclerView, dx, dy) - if (recyclerView.getChildAt(0)?.tag == null) return + val firstChild = recyclerView.getChildAt(0) + if (firstChild == null) { + Logger.w(LOG_TAG_UI, "DnsLogs; err; no child views found in recyclerView") + return + } - val tag: Long = recyclerView.getChildAt(0).tag as Long + val tag = firstChild.tag as? Long + if (tag == null) { + Logger.w(LOG_TAG_UI, "DnsLogs; err; tag is null") + return + } b.queryListRecyclerScrollHeader.text = formatToRelativeTime(requireContext(), tag) @@ -157,6 +184,7 @@ class DnsLogFragment : Fragment(R.layout.fragment_dns_logs), SearchView.OnQueryT } } b.recyclerQuery.addOnScrollListener(scrollListener) + b.recyclerQuery.layoutAnimation = null } private fun remakeFilterChipsUi() { @@ -254,13 +282,17 @@ class DnsLogFragment : Fragment(R.layout.fragment_dns_logs), SearchView.OnQueryT } override fun onQueryTextSubmit(query: String): Boolean { - this.filterValue = query - viewModel.setFilter(filterValue, filterType) + Utilities.delay(QUERY_TEXT_DELAY, lifecycleScope) { + if (this.isAdded) { + this.filterValue = query + viewModel.setFilter(filterValue, filterType) + } + } return true } override fun onQueryTextChange(query: String): Boolean { - Utilities.delay(500, lifecycleScope) { + Utilities.delay(QUERY_TEXT_DELAY, lifecycleScope) { if (this.isAdded) { this.filterValue = query viewModel.setFilter(filterValue, filterType) diff --git a/app/src/full/java/com/celzero/bravedns/ui/fragment/DnsSettingsFragment.kt b/app/src/full/java/com/celzero/bravedns/ui/fragment/DnsSettingsFragment.kt index 14f4c58b6..3e2ed878e 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/fragment/DnsSettingsFragment.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/fragment/DnsSettingsFragment.kt @@ -16,6 +16,7 @@ package com.celzero.bravedns.ui.fragment import Logger +import android.content.DialogInterface import android.content.Intent import android.os.Bundle import android.view.View @@ -40,17 +41,20 @@ import com.celzero.bravedns.ui.activity.ConfigureRethinkBasicActivity import com.celzero.bravedns.ui.activity.DnsListActivity import com.celzero.bravedns.ui.activity.PauseActivity import com.celzero.bravedns.ui.bottomsheet.LocalBlocklistsBottomSheet +import com.celzero.bravedns.util.UIUtils import com.celzero.bravedns.util.UIUtils.fetchColor import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.util.Utilities.isAtleastR import com.celzero.bravedns.util.Utilities.isPlayStoreFlavour +import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.koin.android.ext.android.get import org.koin.android.ext.android.inject import java.util.concurrent.TimeUnit -class DnsSettingsFragment : - Fragment(R.layout.fragment_dns_configure), +class DnsSettingsFragment : Fragment(R.layout.fragment_dns_configure), LocalBlocklistsBottomSheet.OnBottomSheetDialogFragmentDismiss { private val b by viewBinding(FragmentDnsConfigureBinding::bind) @@ -93,18 +97,21 @@ class DnsSettingsFragment : b.dcFaviconSwitch.isChecked = persistentState.fetchFavIcon // prevent dns leaks b.dcPreventDnsLeaksSwitch.isChecked = persistentState.preventDnsLeaks + // enable per-app domain rules (dns alg) + b.dcAlgSwitch.isChecked = persistentState.enableDnsAlg // periodically check for blocklist update b.dcCheckUpdateSwitch.isChecked = persistentState.periodicallyCheckBlocklistUpdate // use custom download manager b.dcDownloaderSwitch.isChecked = persistentState.useCustomDownloadManager - // enable per-app domain rules (dns alg) - b.dcAlgSwitch.isChecked = persistentState.enableDnsAlg // enable dns caching in tunnel b.dcEnableCacheSwitch.isChecked = persistentState.enableDnsCache // proxy dns b.dcProxyDnsSwitch.isChecked = !persistentState.proxyDns - + // use system dns for undelegated domains + b.dcUndelegatedDomainsSwitch.isChecked = persistentState.useSystemDnsForUndelegatedDomains b.connectedStatusTitle.text = getConnectedDnsType() + b.dvBypassDnsBlockSwitch.isChecked = persistentState.bypassBlockInDns + showSplitDnsUi() } private fun updateLocalBlocklistUi() { @@ -131,11 +138,6 @@ class DnsSettingsFragment : } private fun initObservers() { - observeBraveMode() - observeAppState() - } - - private fun observeAppState() { VpnController.connectionStatus.observe(viewLifecycleOwner) { if (it == BraveVPNService.State.PAUSED) { val intent = Intent(requireContext(), PauseActivity::class.java) @@ -145,7 +147,41 @@ class DnsSettingsFragment : appConfig.getConnectedDnsObservable().observe(viewLifecycleOwner) { updateConnectedStatus(it) + updateSelectedDns() + } + } + + private fun showSplitDnsUi() { + if (isAtleastR()) { + // show split dns by default only if the device is running on Android 12 or above + b.dcSplitDnsRl.visibility = View.VISIBLE + b.dcSplitDnsSwitch.isChecked = persistentState.splitDns + } else { + if (persistentState.enableDnsAlg) { + b.dcSplitDnsRl.visibility = View.VISIBLE + b.dcSplitDnsSwitch.isChecked = persistentState.splitDns + } else { + b.dcSplitDnsRl.visibility = View.GONE + b.dcSplitDnsSwitch.isChecked = false + } + } + } + + private fun updateSpiltDns() { + if (isAtleastR()) { + // no-op, no need to depend of alg when device is running on Android 12 or above + // as split dns option is shown to user regardless of dns alg + b.dcSplitDnsRl.visibility = View.VISIBLE + b.dcSplitDnsSwitch.isChecked = persistentState.splitDns + return + } + + if (persistentState.enableDnsAlg) { + persistentState.splitDns = persistentState.splitDns // no-op, added for readability + } else { + persistentState.splitDns = false } + showSplitDnsUi() } private fun updateConnectedStatus(connectedDns: String) { @@ -153,44 +189,47 @@ class DnsSettingsFragment : b.connectedStatusTitleUrl.text = resources.getString(R.string.configure_dns_connected_dns_proxy_status) b.connectedStatusTitle.text = resources.getString(R.string.lbl_wireguard) - disableAllDns() - b.wireguardRb.isEnabled = true return } + var dns = connectedDns + if (persistentState.splitDns && WireguardManager.isAdvancedWgActive()) { + dns += ", " + resources.getString(R.string.lbl_wireguard) + } + when (appConfig.getDnsType()) { AppConfig.DnsType.DOH -> { b.connectedStatusTitleUrl.text = resources.getString(R.string.configure_dns_connected_doh_status) - b.connectedStatusTitle.text = connectedDns + b.connectedStatusTitle.text = dns } AppConfig.DnsType.DOT -> { b.connectedStatusTitleUrl.text = resources.getString(R.string.lbl_dot) - b.connectedStatusTitle.text = connectedDns + b.connectedStatusTitle.text = dns } AppConfig.DnsType.DNSCRYPT -> { b.connectedStatusTitleUrl.text = resources.getString(R.string.configure_dns_connected_dns_crypt_status) - b.connectedStatusTitle.text = connectedDns + b.connectedStatusTitle.text = dns } AppConfig.DnsType.DNS_PROXY -> { b.connectedStatusTitleUrl.text = resources.getString(R.string.configure_dns_connected_dns_proxy_status) - b.connectedStatusTitle.text = connectedDns + b.connectedStatusTitle.text = dns } AppConfig.DnsType.RETHINK_REMOTE -> { b.connectedStatusTitleUrl.text = resources.getString(R.string.configure_dns_connected_doh_status) - b.connectedStatusTitle.text = connectedDns + b.connectedStatusTitle.text = dns } AppConfig.DnsType.SYSTEM_DNS -> { b.connectedStatusTitleUrl.text = resources.getString(R.string.configure_dns_connected_dns_proxy_status) - b.connectedStatusTitle.text = connectedDns + b.connectedStatusTitle.text = dns } AppConfig.DnsType.ODOH -> { b.connectedStatusTitleUrl.text = resources.getString(R.string.lbl_odoh) - b.connectedStatusTitle.text = connectedDns + b.connectedStatusTitle.text = dns } } } @@ -207,25 +246,30 @@ class DnsSettingsFragment : if (WireguardManager.oneWireGuardEnabled()) { b.wireguardRb.visibility = View.VISIBLE b.wireguardRb.isChecked = true + b.wireguardRb.isChecked = true b.wireguardRb.isEnabled = true disableAllDns() return - } else { - b.wireguardRb.visibility = View.GONE } + b.wireguardRb.visibility = View.GONE if (isSystemDns()) { b.networkDnsRb.isChecked = true - return - } - - if (isRethinkDns()) { + b.rethinkPlusDnsRb.isChecked = false + b.customDnsRb.isChecked = false + b.networkDnsRb.isChecked = true + } else if (isRethinkDns()) { b.rethinkPlusDnsRb.isChecked = true - return + b.customDnsRb.isChecked = false + b.networkDnsRb.isChecked = false + b.rethinkPlusDnsRb.isChecked = true + } else { + // connected to custom dns, update the dns details + b.customDnsRb.isChecked = true + b.rethinkPlusDnsRb.isChecked = false + b.networkDnsRb.isChecked = false + b.customDnsRb.isChecked = true } - - // connected to custom dns, update the dns details - b.customDnsRb.isChecked = true } private fun disableAllDns() { @@ -266,10 +310,6 @@ class DnsSettingsFragment : } } - private fun observeBraveMode() { - appConfig.getConnectedDnsObservable().observe(viewLifecycleOwner) { updateSelectedDns() } - } - private fun initClickListeners() { b.dcLocalBlocklistRl.setOnClickListener { openLocalBlocklist() } @@ -280,13 +320,6 @@ class DnsSettingsFragment : b.dcCheckUpdateSwitch.isChecked = !b.dcCheckUpdateSwitch.isChecked } - b.dcAlgSwitch.setOnCheckedChangeListener { _: CompoundButton, enabled: Boolean -> - enableAfterDelay(TimeUnit.SECONDS.toMillis(1), b.dcAlgSwitch) - persistentState.enableDnsAlg = enabled - } - - b.dcAlgRl.setOnClickListener { b.dcAlgSwitch.isChecked = !b.dcAlgSwitch.isChecked } - b.dcCheckUpdateSwitch.setOnCheckedChangeListener { _: CompoundButton, enabled: Boolean -> persistentState.periodicallyCheckBlocklistUpdate = enabled if (enabled) { @@ -301,6 +334,14 @@ class DnsSettingsFragment : } } + b.dcAlgSwitch.setOnCheckedChangeListener { _: CompoundButton, enabled: Boolean -> + enableAfterDelay(TimeUnit.SECONDS.toMillis(1), b.dcAlgSwitch) + persistentState.enableDnsAlg = enabled + updateSpiltDns() + } + + b.dcAlgRl.setOnClickListener { b.dcAlgSwitch.isChecked = !b.dcAlgSwitch.isChecked } + b.dcFaviconRl.setOnClickListener { b.dcFaviconSwitch.isChecked = !b.dcFaviconSwitch.isChecked } @@ -320,17 +361,27 @@ class DnsSettingsFragment : persistentState.preventDnsLeaks = enabled } + b.rethinkPlusDnsRb.setOnCheckedChangeListener(null) b.rethinkPlusDnsRb.setOnClickListener { // rethink dns plus invokeRethinkActivity(ConfigureRethinkBasicActivity.FragmentLoader.DB_LIST) } + b.customDnsRb.setOnCheckedChangeListener(null) b.customDnsRb.setOnClickListener { // custom dns showCustomDns() } + b.networkDnsRb.setOnCheckedChangeListener(null) b.networkDnsRb.setOnClickListener { + if (isSystemDns()) { + io { + val sysDns = VpnController.getSystemDns() + uiCtx { showSystemDnsDialog(sysDns) } + } + return@setOnClickListener + } // network dns proxy setNetworkDns() } @@ -374,6 +425,61 @@ class DnsSettingsFragment : } } } + + b.dvBypassDnsBlockSwitch.setOnCheckedChangeListener { _, isChecked -> + persistentState.bypassBlockInDns = isChecked + } + + b.dvBypassDnsBlockRl.setOnClickListener { + b.dvBypassDnsBlockSwitch.isChecked = !b.dvBypassDnsBlockSwitch.isChecked + } + + b.dcSplitDnsSwitch.setOnCheckedChangeListener { _, isChecked -> + persistentState.splitDns = isChecked + } + + b.dcSplitDnsRl.setOnClickListener { + b.dcSplitDnsSwitch.isChecked = !b.dcSplitDnsSwitch.isChecked + } + + b.networkDnsInfo.setOnClickListener { + io { + val sysDns = VpnController.getSystemDns() + uiCtx { showSystemDnsDialog(sysDns) } + } + } + + b.dcUndelegatedDomainsRl.setOnClickListener { + b.dcUndelegatedDomainsSwitch.isChecked = !b.dcUndelegatedDomainsSwitch.isChecked + } + + b.dcUndelegatedDomainsSwitch.setOnCheckedChangeListener { _, isChecked -> + persistentState.useSystemDnsForUndelegatedDomains = isChecked + } + } + + private fun showSystemDnsDialog(dns: String) { + val builder = MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.network_dns) + .setMessage(dns) + .setCancelable(true) + .setPositiveButton(R.string.ada_noapp_dialog_positive) { di, _ -> + di.dismiss() + } + .setNeutralButton(requireContext().getString(R.string.dns_info_neutral)) { _: DialogInterface, _: Int -> + UIUtils.clipboardCopy( + requireContext(), + dns, + requireContext().getString(R.string.copy_clipboard_label) + ) + Utilities.showToastUiCentered( + requireContext(), + requireContext().getString(R.string.info_dialog_url_copy_toast_msg), + Toast.LENGTH_SHORT + ) + } + val dialog = builder.create() + dialog.show() } private fun initAnimation() { @@ -405,7 +511,6 @@ class DnsSettingsFragment : private fun showCustomDns() { val intent = Intent(requireContext(), DnsListActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED startActivity(intent) } @@ -428,6 +533,10 @@ class DnsSettingsFragment : lifecycleScope.launch(Dispatchers.IO) { f() } } + private suspend fun uiCtx(f: suspend () -> Unit) { + withContext(Dispatchers.Main) { f() } + } + override fun onBtmSheetDismiss() { if (!isAdded) return diff --git a/app/src/full/java/com/celzero/bravedns/ui/fragment/FirewallSettingsFragment.kt b/app/src/full/java/com/celzero/bravedns/ui/fragment/FirewallSettingsFragment.kt index bcb89cbb6..f93d1a04b 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/fragment/FirewallSettingsFragment.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/fragment/FirewallSettingsFragment.kt @@ -50,10 +50,6 @@ class FirewallSettingsFragment : Fragment(R.layout.fragment_firewall_settings) { private fun openCustomIpScreen() { val intent = Intent(requireContext(), CustomRulesActivity::class.java) - // this activity is either being started in a new task or bringing to the top an - // existing task, then it will be launched as the front door of the task. - // This will result in the application to have that task in the proper state. - intent.flags = Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED intent.putExtra( Constants.VIEW_PAGER_SCREEN_TO_LOAD, CustomRulesActivity.Tabs.IP_RULES.screen @@ -68,7 +64,6 @@ class FirewallSettingsFragment : Fragment(R.layout.fragment_firewall_settings) { private fun openAppWiseIpScreen() { val intent = Intent(requireContext(), CustomRulesActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED intent.putExtra( Constants.VIEW_PAGER_SCREEN_TO_LOAD, CustomRulesActivity.Tabs.IP_RULES.screen @@ -80,7 +75,6 @@ class FirewallSettingsFragment : Fragment(R.layout.fragment_firewall_settings) { private fun openUniversalFirewallScreen() { val intent = Intent(requireContext(), UniversalFirewallSettingsActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED startActivity(intent) } } diff --git a/app/src/full/java/com/celzero/bravedns/ui/fragment/HomeScreenFragment.kt b/app/src/full/java/com/celzero/bravedns/ui/fragment/HomeScreenFragment.kt index 909a047b2..38ff28a25 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/fragment/HomeScreenFragment.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/fragment/HomeScreenFragment.kt @@ -38,23 +38,29 @@ import android.os.SystemClock import android.provider.Settings import android.text.format.DateUtils import android.util.TypedValue +import android.view.LayoutInflater import android.view.View import android.widget.Toast import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.widget.AppCompatTextView import androidx.core.content.ContextCompat import androidx.core.net.toUri import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope -import backend.Backend +import com.celzero.firestack.backend.Backend import by.kirich1409.viewbindingdelegate.viewBinding import com.celzero.bravedns.R import com.celzero.bravedns.data.AppConfig import com.celzero.bravedns.database.AppInfo import com.celzero.bravedns.databinding.FragmentHomeScreenBinding +import com.celzero.bravedns.net.doh.Transaction +import com.celzero.bravedns.rpnproxy.RpnProxyManager import com.celzero.bravedns.scheduler.WorkScheduler import com.celzero.bravedns.service.* +import com.celzero.bravedns.service.WireguardManager.WG_HANDSHAKE_TIMEOUT +import com.celzero.bravedns.service.WireguardManager.WG_UPTIME_THRESHOLD import com.celzero.bravedns.ui.activity.AlertsActivity import com.celzero.bravedns.ui.activity.AppListActivity import com.celzero.bravedns.ui.activity.ConfigureRethinkBasicActivity @@ -72,12 +78,14 @@ import com.celzero.bravedns.util.* import com.celzero.bravedns.util.Constants.Companion.RETHINKDNS_SPONSOR_LINK import com.celzero.bravedns.util.UIUtils.openAppInfo import com.celzero.bravedns.util.UIUtils.openNetworkSettings +import com.celzero.bravedns.util.UIUtils.openUrl import com.celzero.bravedns.util.UIUtils.openVpnProfile import com.celzero.bravedns.util.UIUtils.updateHtmlEncodedText import com.celzero.bravedns.util.Utilities.delay import com.celzero.bravedns.util.Utilities.getPrivateDnsMode import com.celzero.bravedns.util.Utilities.isAtleastN import com.celzero.bravedns.util.Utilities.isAtleastP +import com.celzero.bravedns.util.Utilities.isAtleastR import com.celzero.bravedns.util.Utilities.isAtleastU import com.celzero.bravedns.util.Utilities.isOtherVpnHasAlwaysOn import com.celzero.bravedns.util.Utilities.isPrivateDnsActive @@ -107,6 +115,10 @@ class HomeScreenFragment : Fragment(R.layout.fragment_home_screen) { private lateinit var startForResult: ActivityResultLauncher private lateinit var notificationPermissionResult: ActivityResultLauncher + companion object { + private const val TAG = "HSFragment" + } + enum class ScreenType { DNS, FIREWALL, @@ -125,14 +137,13 @@ class HomeScreenFragment : Fragment(R.layout.fragment_home_screen) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + Logger.v(LOG_TAG_UI, "$TAG: init view in home screen fragment") initializeValues() initializeClickListeners() observeVpnState() } private fun initializeValues() { - isVpnActivated = VpnController.state().activationRequested - themeNames = arrayOf( getString(R.string.settings_theme_dialog_themes_1), @@ -143,20 +154,35 @@ class HomeScreenFragment : Fragment(R.layout.fragment_home_screen) { appConfig.getBraveModeObservable().postValue(appConfig.getBraveMode().mode) b.fhsCardLogsTv.text = getString(R.string.lbl_logs).replaceFirstChar(Char::titlecase) + + // do not show the sponsor card if the rethink plus is enabled + if (RpnProxyManager.isRpnActive()) { + b.fhsSponsor.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.ic_rethink_plus_sparkle)) + b.fhsSponsor.visibility = View.VISIBLE + } else { + b.fhsSponsor.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.ic_heart_accent)) + b.fhsSponsor.visibility = View.VISIBLE + } } private fun initializeClickListeners() { b.fhsCardFirewallLl.setOnClickListener { + Logger.v(LOG_TAG_UI, "$TAG: click event on firewall card") startFirewallActivity(FirewallActivity.Tabs.UNIVERSAL.screen) } - b.fhsCardAppsCv.setOnClickListener { startAppsActivity() } + b.fhsCardAppsCv.setOnClickListener { + Logger.v(LOG_TAG_UI, "$TAG: click event on apps card") + startAppsActivity() + } b.fhsCardDnsLl.setOnClickListener { + Logger.v(LOG_TAG_UI, "$TAG: click event on dns card") startDnsActivity(DnsDetailActivity.Tabs.CONFIGURE.screen) } b.homeFragmentBottomSheetIcon.setOnClickListener { + Logger.v(LOG_TAG_UI, "$TAG: click event on bottom sheet icon") b.homeFragmentBottomSheetIcon.isEnabled = false openBottomSheet() delay(TimeUnit.MILLISECONDS.toMillis(500), lifecycleScope) { @@ -164,9 +190,13 @@ class HomeScreenFragment : Fragment(R.layout.fragment_home_screen) { } } - b.homeFragmentPauseIcon.setOnClickListener { handlePause() } + b.homeFragmentPauseIcon.setOnClickListener { + Logger.v(LOG_TAG_UI, "$TAG: click event on pause icon") + handlePause() + } b.fhsDnsOnOffBtn.setOnClickListener { + Logger.v(LOG_TAG_UI, "$TAG: click event on main button") handleMainScreenBtnClickEvent() delay(TimeUnit.MILLISECONDS.toMillis(500), lifecycleScope) { if (isAdded) { @@ -176,15 +206,18 @@ class HomeScreenFragment : Fragment(R.layout.fragment_home_screen) { } appConfig.getBraveModeObservable().observe(viewLifecycleOwner) { + Logger.v(LOG_TAG_UI, "$TAG: brave mode changed to $it") updateCardsUi() syncDnsStatus() } b.fhsCardLogsLl.setOnClickListener { + Logger.v(LOG_TAG_UI, "$TAG: click event on logs card") startActivity(ScreenType.LOGS, NetworkLogsActivity.Tabs.NETWORK_LOGS.screen) } b.fhsCardProxyLl.setOnClickListener { + Logger.v(LOG_TAG_UI, "$TAG: click event on proxy card") if (appConfig.isWireGuardEnabled()) { startActivity(ScreenType.PROXY_WIREGUARD) } else { @@ -193,19 +226,75 @@ class HomeScreenFragment : Fragment(R.layout.fragment_home_screen) { } b.fhsSponsor.setOnClickListener { - val intent = Intent(Intent.ACTION_VIEW, RETHINKDNS_SPONSOR_LINK.toUri()) - startActivity(intent) + if (RpnProxyManager.isRpnActive()) { + Logger.d(LOG_TAG_UI, "RPlus is enabled, not showing sponsor dialog") + return@setOnClickListener + } + Logger.v(LOG_TAG_UI, "$TAG: click event on sponsor card") + if (RpnProxyManager.isRpnActive()) { + Logger.d(LOG_TAG_UI, "RPlus is enabled, not showing sponsor dialog") + return@setOnClickListener + } + promptForAppSponsorship() + } + + b.fhsSponsorBottom.setOnClickListener { + Logger.v(LOG_TAG_UI, "$TAG: click event on sponsor card") + if (RpnProxyManager.isRpnActive()) { + Logger.d(LOG_TAG_UI, "RPlus is enabled, not showing sponsor dialog") + return@setOnClickListener + } + promptForAppSponsorship() } b.fhsTitleRethink.setOnClickListener { - val intent = Intent(Intent.ACTION_VIEW, RETHINKDNS_SPONSOR_LINK.toUri()) - startActivity(intent) + Logger.v(LOG_TAG_UI, "$TAG: click event on rethink card") + if (RpnProxyManager.isRpnActive()) { + Logger.d(LOG_TAG_UI, "RPlus is enabled, not showing sponsor dialog") + return@setOnClickListener + } + promptForAppSponsorship() } // comment out the below code to disable the alerts card (v0.5.5b) // b.fhsCardAlertsLl.setOnClickListener { startActivity(ScreenType.ALERTS) } } + private fun promptForAppSponsorship() { + val installTime = requireContext().packageManager.getPackageInfo( + requireContext().packageName, + 0 + ).firstInstallTime + val timeDiff = System.currentTimeMillis() - installTime + // convert it to month + val days = (timeDiff / (1000 * 60 * 60 * 24)).toDouble() + val month = days / 30 + // multiply the month with 0.60$ + 0.20$ for every month + val amount = month * (0.60 + 0.20) + Logger.d(LOG_TAG_UI, "Sponsor: $installTime, days/month: $days/$month, amount: $amount") + val alertBuilder = MaterialAlertDialogBuilder(requireContext()) + val inflater = LayoutInflater.from(requireContext()) + val dialogView = inflater.inflate(R.layout.dialog_sponsor_info, null) + alertBuilder.setView(dialogView) + alertBuilder.setCancelable(true) + + val amountTxt = dialogView.findViewById(R.id.dialog_sponsor_info_amount) + val usageTxt = dialogView.findViewById(R.id.dialog_sponsor_info_usage) + val sponsorBtn = dialogView.findViewById(R.id.dialog_sponsor_info_sponsor) + + val dialog = alertBuilder.create() + + val msg = getString(R.string.sponser_dialog_usage_msg, days.toInt().toString(), "%.2f".format(amount)) + amountTxt.text = getString(R.string.two_argument_no_space, getString(R.string.symbol_dollar), "%.2f".format(amount)) + usageTxt.text = msg + + sponsorBtn.setOnClickListener { + openUrl(requireContext(), RETHINKDNS_SPONSOR_LINK) + } + + dialog.show() + } + private fun handlePause() { if (!VpnController.hasTunnel()) { showToastUiCentered( @@ -379,11 +468,24 @@ class HomeScreenFragment : Fragment(R.layout.fragment_home_screen) { b.fhsCardAlertsApps.isSelected = true } } */ + private var proxyStateListenerJob: Job? = null private fun observeProxyStates() { persistentState.getProxyStatus().observe(viewLifecycleOwner) { + Logger.vv(LOG_TAG_UI, "$TAG proxy state changed to $it") if (it != -1) { - updateUiWithProxyStates(it) + if (proxyStateListenerJob?.isActive == true) { + Logger.vv(LOG_TAG_UI, "$TAG cancel prev proxy state listener job") + proxyStateListenerJob?.cancel() + proxyStateListenerJob = null + } + proxyStateListenerJob = ui("proxyStates") { + while (isVisible && isAdded) { + updateUiWithProxyStates(it) + kotlinx.coroutines.delay(2500L) + } + proxyStateListenerJob?.cancel() + } } else { b.fhsCardProxyCount.text = getString(R.string.lbl_inactive) b.fhsCardOtherProxyCount.visibility = View.VISIBLE @@ -393,21 +495,72 @@ class HomeScreenFragment : Fragment(R.layout.fragment_home_screen) { } private fun updateUiWithProxyStates(resId: Int) { + if ( + !viewLifecycleOwner + .lifecycle + .currentState + .isAtLeast(androidx.lifecycle.Lifecycle.State.STARTED) + ) { + proxyStateListenerJob?.cancel() + return + } + + if (!isVpnActivated) { + disableProxyCard() + return + } + // get proxy type from app config val proxyType = AppConfig.ProxyType.of(appConfig.getProxyType()) if (proxyType.isProxyTypeWireguard()) { io { - val proxies = WireguardManager.getEnabledConfigs() + val proxies = WireguardManager.getActiveConfigs() var active = 0 var failing = 0 + var idle = 0 + val now = System.currentTimeMillis() + Logger.v(LOG_TAG_UI, "$TAG wg active proxies: ${proxies.size}") proxies.forEach { val proxyId = "${ProxyManager.ID_WG_BASE}${it.getId()}" - val status = VpnController.getProxyStatusById(proxyId) + Logger.vv(LOG_TAG_UI, "$TAG init stats check for $proxyId") + val stats = VpnController.getProxyStats(proxyId) + // check for dns status of the wg if splitDns is enabled + val dnsStats = if (isSplitDns()) { + VpnController.getDnsStatus(proxyId) + } else { + null + } + + if (stats == null) { + failing++ + return@forEach + } + if (dnsStats != null && isDnsError(dnsStats)) { + failing++ + return@forEach + } // else proceed + + val lastOk = stats.lastOK + val since = stats.since + if (now - since > WG_UPTIME_THRESHOLD && lastOk == 0L) { + failing++ + return@forEach + } + val status = VpnController.getProxyStatusById(proxyId).first if (status != null) { // consider starting and up as active - if (status == Backend.TOK || status == Backend.TUP || status == Backend.TZZ) { - active++ + if (status == Backend.TZZ) { + idle++ + } else if (status == Backend.TOK || status == Backend.TUP) { + val isUp = System.currentTimeMillis() - stats.lastOK < WG_HANDSHAKE_TIMEOUT + if (isUp) { + active++ + } else { + // some wg conns like free proton, reply to handshakes but do not + // reply to data msgs, in that case the status will be TZZ + failing++ + } } else { failing++ } @@ -416,30 +569,46 @@ class HomeScreenFragment : Fragment(R.layout.fragment_home_screen) { } } uiCtx { + if (!isVisible || !isAdded) return@uiCtx b.fhsCardOtherProxyCount.visibility = View.VISIBLE - // show as 3 active 1 failing, if failing is 0 show as 4 active + var text = "" + // show as 3 active 1 failing 1 idle, if failing is 0 show as 4 active + if (active > 0) { + text = getString( + R.string.two_argument_space, + active.toString(), + getString(R.string.lbl_active) + ) + } if (failing > 0) { - b.fhsCardProxyCount.text = - getString( - R.string.orbot_stop_dialog_message_combo, - getString( - R.string.two_argument_space, - active.toString(), - getString(R.string.lbl_active) - ), - getString( - R.string.two_argument_space, - failing.toString(), - getString(R.string.status_failing).replaceFirstChar(Char::titlecase) - ) - ) + text += if (text.isNotEmpty()) { + "\n" + } else { + "" + } + text += getString( + R.string.two_argument_space, + failing.toString(), + getString(R.string.status_failing).replaceFirstChar(Char::titlecase) + ) + } + if (idle > 0) { + text += if (text.isNotEmpty()) { + "\n" + } else { + "" + } + text += getString( + R.string.two_argument_space, + idle.toString(), + getString(R.string.lbl_idle).replaceFirstChar(Char::titlecase) + ) + } + Logger.v(LOG_TAG_UI, "$TAG overall wg proxy status: $text") + if (text.isEmpty()) { + b.fhsCardProxyCount.text = getString(R.string.lbl_inactive) } else { - b.fhsCardProxyCount.text = - getString( - R.string.two_argument_space, - active.toString(), - getString(R.string.lbl_active) - ) + b.fhsCardProxyCount.text = text } } } @@ -450,6 +619,22 @@ class HomeScreenFragment : Fragment(R.layout.fragment_home_screen) { b.fhsCardOtherProxyCount.text = getString(resId) } + private fun isSplitDns(): Boolean { + // by default, the split dns is enabled for android R and above, as we know the app + // which sends dns queries + if (isAtleastR()) return true + + return persistentState.splitDns + } + + private fun isDnsError(statusId: Long?): Boolean { + if (statusId == null) return true + + val s = Transaction.Status.fromId(statusId) + return s == Transaction.Status.BAD_QUERY || s == Transaction.Status.BAD_RESPONSE || s == Transaction.Status.NO_RESPONSE || s == Transaction.Status.SEND_FAIL || s == Transaction.Status.CLIENT_ERROR || s == Transaction.Status.INTERNAL_ERROR || s == Transaction.Status.TRANSPORT_ERROR + } + + private fun unobserveProxyStates() { persistentState.getProxyStatus().removeObservers(viewLifecycleOwner) } @@ -461,6 +646,7 @@ class HomeScreenFragment : Fragment(R.layout.fragment_home_screen) { } private fun disableProxyCard() { + proxyStateListenerJob?.cancel() b.fhsCardProxyCount.text = getString(R.string.lbl_inactive) b.fhsCardOtherProxyCount.visibility = View.VISIBLE b.fhsCardOtherProxyCount.text = getString(R.string.lbl_disabled) @@ -492,40 +678,66 @@ class HomeScreenFragment : Fragment(R.layout.fragment_home_screen) { * are register to update the UI in the home screen */ private fun observeDnsStates() { - persistentState.median.observe(viewLifecycleOwner) { - // show status as very fast, fast, slow, and very slow based on the latency - if (it in 0L..19L) { - val string = - getString( - R.string.ci_desc, - getString(R.string.lbl_very), - getString(R.string.lbl_fast) - ) - .replaceFirstChar(Char::titlecase) - b.fhsCardDnsLatency.text = string - } else if (it in 20L..50L) { - b.fhsCardDnsLatency.text = - getString(R.string.lbl_fast).replaceFirstChar(Char::titlecase) - } else if (it in 50L..100L) { - b.fhsCardDnsLatency.text = - getString(R.string.lbl_slow).replaceFirstChar(Char::titlecase) + io { + val dnsId = if (WireguardManager.oneWireGuardEnabled()) { + val id = WireguardManager.getOneWireGuardProxyId() + if (id == null) { + Backend.Preferred + } else { + "${ProxyManager.ID_WG_BASE}${id}" + } } else { - val string = - getString( - R.string.ci_desc, - getString(R.string.lbl_very), - getString(R.string.lbl_slow) - ) - .replaceFirstChar(Char::titlecase) - b.fhsCardDnsLatency.text = string + Backend.Preferred } + val p50 = VpnController.p50(dnsId) + uiCtx { + when (p50) { + in 0L..19L -> { + val string = + getString( + R.string.ci_desc, + getString(R.string.lbl_very), + getString(R.string.lbl_fast) + ) + .replaceFirstChar(Char::titlecase) + b.fhsCardDnsLatency.text = string + } + + in 20L..50L -> { + b.fhsCardDnsLatency.text = + getString(R.string.lbl_fast).replaceFirstChar(Char::titlecase) + } - b.fhsCardDnsLatency.isSelected = true + in 50L..100L -> { + b.fhsCardDnsLatency.text = + getString(R.string.lbl_slow).replaceFirstChar(Char::titlecase) + } + + else -> { + val string = + getString( + R.string.ci_desc, + getString(R.string.lbl_very), + getString(R.string.lbl_slow) + ) + .replaceFirstChar(Char::titlecase) + b.fhsCardDnsLatency.text = string + } + } + + b.fhsCardDnsLatency.isSelected = true + } } appConfig.getConnectedDnsObservable().observe(viewLifecycleOwner) { updateUiWithDnsStates(it) } + + VpnController.getRegionLiveData().observe(viewLifecycleOwner) { + if (it != null) { + b.fhsCardRegion.text = it.uppercase() + } + } } private fun updateUiWithDnsStates(dnsName: String) { @@ -542,30 +754,38 @@ class HomeScreenFragment : Fragment(R.layout.fragment_home_screen) { "${ProxyManager.ID_WG_BASE}${id}" } } else { + if (persistentState.splitDns && WireguardManager.isAdvancedWgActive()) { + dns += ", " + resources.getString(R.string.lbl_wireguard) + } + preferredId } if (VpnController.isOn()) { - ui("dnsStatusCheck") { + io { var failing = false repeat(5) { val status = VpnController.getDnsStatus(id) if (status != null) { failing = false - if (isAdded) { - b.fhsCardDnsLatency.visibility = View.VISIBLE - b.fhsCardDnsFailure.visibility = View.GONE + uiCtx { + if (isAdded) { + b.fhsCardDnsLatency.visibility = View.VISIBLE + b.fhsCardDnsFailure.visibility = View.INVISIBLE + } } - return@ui + return@io } // status null means the dns transport is not active / different id is used kotlinx.coroutines.delay(1000L) failing = true } - if (failing && isAdded) { - b.fhsCardDnsLatency.visibility = View.GONE - b.fhsCardDnsFailure.visibility = View.VISIBLE - b.fhsCardDnsFailure.text = getString(R.string.failed_using_default) + uiCtx { + if (failing && isAdded) { + b.fhsCardDnsLatency.visibility = View.INVISIBLE + b.fhsCardDnsFailure.visibility = View.VISIBLE + b.fhsCardDnsFailure.text = getString(R.string.failed_using_default) + } } } } @@ -619,8 +839,8 @@ class HomeScreenFragment : Fragment(R.layout.fragment_home_screen) { // unregister all dns related observers private fun unobserveDnsStates() { - persistentState.median.removeObservers(viewLifecycleOwner) appConfig.getConnectedDnsObservable().removeObservers(viewLifecycleOwner) + VpnController.getRegionLiveData().removeObservers(viewLifecycleOwner) } private fun observeUniversalStates() { @@ -913,12 +1133,14 @@ class HomeScreenFragment : Fragment(R.layout.fragment_home_screen) { override fun onResume() { super.onResume() + isVpnActivated = VpnController.state().activationRequested handleShimmer() maybeAutoStartVpn() updateCardsUi() syncDnsStatus() handleLockdownModeIfNeeded() startTrafficStats() + b.fhsSponsorBottom.bringToFront() } private lateinit var trafficStatsTicker: Job @@ -926,15 +1148,60 @@ class HomeScreenFragment : Fragment(R.layout.fragment_home_screen) { private fun startTrafficStats() { trafficStatsTicker = ui("trafficStatsTicker") { + var counter = 0 while (true) { - fetchTrafficStats() - kotlinx.coroutines.delay(1500L) + // make it as 3 options and add the protos + if (!isAdded) return@ui + + if (counter % 3 == 0) { + displayTrafficStatsRate() + } else if (counter % 3 == 1) { + displayTrafficStatsBW() + } else { + displayProtos() + } + // show protos + kotlinx.coroutines.delay(2500L) + counter++ } } } + private fun displayProtos() { + b.fhsInternetSpeed.visibility = View.VISIBLE + b.fhsInternetSpeedUnit.visibility = View.VISIBLE + b.fhsInternetSpeed.text = VpnController.protocols() + b.fhsInternetSpeedUnit.text = getString(R.string.lbl_protos) + } + + private fun displayTrafficStatsBW() { + val txRx = convertToCommonUnit(txRx.tx, txRx.rx) + + b.fhsInternetSpeed.visibility = View.VISIBLE + b.fhsInternetSpeedUnit.visibility = View.VISIBLE + b.fhsInternetSpeed.text = + getString( + R.string.two_argument_space, + getString( + R.string.two_argument_space, + txRx.first, + getString(R.string.symbol_black_up) + ), + getString( + R.string.two_argument_space, + txRx.second, + getString(R.string.symbol_black_down) + ) + ) + b.fhsInternetSpeedUnit.text = getCommonUnit(this.txRx.tx, this.txRx.rx) + } + private fun stopTrafficStats() { - trafficStatsTicker.cancel() + try { + trafficStatsTicker.cancel() + } catch (e: Exception) { + Logger.e(LOG_TAG_VPN, "error stopping traffic stats ticker", e) + } } data class TxRx( @@ -945,7 +1212,7 @@ class HomeScreenFragment : Fragment(R.layout.fragment_home_screen) { private var txRx = TxRx() - private fun fetchTrafficStats() { + private fun displayTrafficStatsRate() { val curr = TxRx() if (txRx.time <= 0L) { txRx = curr @@ -960,12 +1227,10 @@ class HomeScreenFragment : Fragment(R.layout.fragment_home_screen) { b.fhsInternetSpeedUnit.visibility = View.GONE return } - val tx = curr.tx - txRx.tx val rx = curr.rx - txRx.rx txRx = curr - val txBytes = String.format("%.2f", ((tx / dur) / 1000.0)) - val rxBytes = String.format("%.2f", ((rx / dur) / 1000.0)) + val txRx = convertToCommonUnit(tx/dur, rx/dur) b.fhsInternetSpeed.visibility = View.VISIBLE b.fhsInternetSpeedUnit.visibility = View.VISIBLE b.fhsInternetSpeed.text = @@ -973,17 +1238,47 @@ class HomeScreenFragment : Fragment(R.layout.fragment_home_screen) { R.string.two_argument_space, getString( R.string.two_argument_space, - txBytes, + txRx.first, getString(R.string.symbol_black_up) ), getString( R.string.two_argument_space, - rxBytes, + txRx.second, getString(R.string.symbol_black_down) ) ) + b.fhsInternetSpeedUnit.text = getString(R.string.symbol_ps, getCommonUnit(tx/dur, rx/dur)) } + // TODO: Move this to a common utility class + private fun getCommonUnit(bytes1: Long, bytes2: Long): String { + val maxBytes = maxOf(bytes1, bytes2) + return when { + maxBytes >= 1024L * 1024L * 1024L * 1024L -> "TB" + maxBytes >= 1024L * 1024L * 1024L -> "GB" + maxBytes >= 1024L * 1024L -> "MB" + maxBytes >= 1024L -> "KB" + else -> "B" + } + } + + private fun convertToCommonUnit(bytes1: Long, bytes2: Long): Pair { + val unit = getCommonUnit(bytes1, bytes2) + val v = when (unit) { + "TB" -> Pair(bytesToTB(bytes1), bytesToTB(bytes2)) + "GB" -> Pair(bytesToGB(bytes1), bytesToGB(bytes2)) + "MB" -> Pair(bytesToMB(bytes1), bytesToMB(bytes2)) + "KB" -> Pair(bytesToKB(bytes1), bytesToKB(bytes2)) + else -> Pair(bytes1.toDouble(), bytes2.toDouble()) + } + return Pair(String.format(Locale.ROOT, "%.2f", v.first), String.format(Locale.ROOT, "%.2f", v.second)) + } + + private fun bytesToKB(bytes: Long): Double = bytes / 1024.0 + private fun bytesToMB(bytes: Long): Double = bytes / (1024.0 * 1024.0) + private fun bytesToGB(bytes: Long): Double = bytes / (1024.0 * 1024.0 * 1024.0) + private fun bytesToTB(bytes: Long): Double = bytes / (1024.0 * 1024.0 * 1024.0 * 1024.0) + /** * Issue fix - https://github.com/celzero/rethink-app/issues/57 When the application * crashes/updates it goes into red waiting state. This causes confusion to the users also @@ -1033,6 +1328,7 @@ class HomeScreenFragment : Fragment(R.layout.fragment_home_screen) { super.onPause() stopShimmer() stopTrafficStats() + proxyStateListenerJob?.cancel() } private fun startDnsActivity(screenToLoad: Int) { @@ -1082,7 +1378,6 @@ class HomeScreenFragment : Fragment(R.layout.fragment_home_screen) { // no need to check for app modes to open this activity // one use case: https://github.com/celzero/rethink-app/issues/611 val intent = Intent(requireContext(), AppListActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED startActivity(intent) } @@ -1100,7 +1395,6 @@ class HomeScreenFragment : Fragment(R.layout.fragment_home_screen) { ScreenType.PROXY_WIREGUARD -> Intent(requireContext(), WgMainActivity::class.java) } - intent.flags = Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED if (type == ScreenType.RETHINK) { io { val url = appConfig.getRemoteRethinkEndpoint()?.url @@ -1138,7 +1432,7 @@ class HomeScreenFragment : Fragment(R.layout.fragment_home_screen) { } private fun stopVpnService() { - VpnController.stop(requireContext()) + VpnController.stop("home", requireContext()) } private fun startVpnService() { @@ -1266,6 +1560,7 @@ class HomeScreenFragment : Fragment(R.layout.fragment_home_screen) { // Sets the UI DNS status on/off. private fun syncDnsStatus() { val status = VpnController.state() + val isEch = status.serverName?.contains(DnsLogTracker.ECH, true) == true // Change status and explanation text var statusId: Int @@ -1283,7 +1578,7 @@ class HomeScreenFragment : Fragment(R.layout.fragment_home_screen) { status.connectionState == null -> { // app's waiting here, but such a status is a cause for confusion // R.string.status_waiting - R.string.status_protected + R.string.status_no_internet } status.connectionState === BraveVPNService.State.NEW -> { // app's starting here, but such a status confuses users @@ -1393,8 +1688,16 @@ class HomeScreenFragment : Fragment(R.layout.fragment_home_screen) { b.fhsProtectionLevelTxt.setTextColor(colorId) b.fhsProtectionLevelTxt.text = string } else { - b.fhsProtectionLevelTxt.setTextColor(colorId) - b.fhsProtectionLevelTxt.setText(statusId) + if (isEch) { + val stat = getString(statusId) + val s = stat.replaceFirst(getString(R.string.status_protected), getString(R.string.lbl_ultra_secure), true) + Logger.d(LOG_TAG_UI, "Ech status : $stat") + b.fhsProtectionLevelTxt.setTextColor(fetchTextColor(R.color.accentGood)) + b.fhsProtectionLevelTxt.text = s + } else { + b.fhsProtectionLevelTxt.setTextColor(colorId) + b.fhsProtectionLevelTxt.setText(statusId) + } } } @@ -1412,20 +1715,28 @@ class HomeScreenFragment : Fragment(R.layout.fragment_home_screen) { private fun fetchTextColor(attr: Int): Int { val attributeFetch = - if (attr == R.color.accentGood) { - R.attr.accentGood - } else if (attr == R.color.accentBad) { - R.attr.accentBad - } else if (attr == R.color.primaryLightColorText) { - R.attr.primaryLightColorText - } else if (attr == R.color.secondaryText) { - R.attr.invertedPrimaryTextColor - } else if (attr == R.color.primaryText) { - R.attr.primaryTextColor - } else if (attr == R.color.primaryTextLight) { - R.attr.primaryTextColor - } else { - R.attr.accentGood + when (attr) { + R.color.accentGood -> { + R.attr.accentGood + } + R.color.accentBad -> { + R.attr.accentBad + } + R.color.primaryLightColorText -> { + R.attr.primaryLightColorText + } + R.color.secondaryText -> { + R.attr.invertedPrimaryTextColor + } + R.color.primaryText -> { + R.attr.primaryTextColor + } + R.color.primaryTextLight -> { + R.attr.primaryTextColor + } + else -> { + R.attr.accentGood + } } val typedValue = TypedValue() val a: TypedArray = diff --git a/app/src/full/java/com/celzero/bravedns/ui/fragment/RethinkBlocklistFragment.kt b/app/src/full/java/com/celzero/bravedns/ui/fragment/RethinkBlocklistFragment.kt index ba19e6de9..b333e9da6 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/fragment/RethinkBlocklistFragment.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/fragment/RethinkBlocklistFragment.kt @@ -170,6 +170,7 @@ class RethinkBlocklistFragment : override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + Logger.v(LOG_TAG_UI, "init Rethink blocklist fragment") init() initObservers() initClickListeners() @@ -269,14 +270,6 @@ class RethinkBlocklistFragment : } } - private fun currentBlocklistTimeStamp(): Long { - return if (type.isLocal()) { - persistentState.localBlocklistTimestamp - } else { - persistentState.remoteBlocklistTimestamp - } - } - private fun showDownloadUi() { if (type.isLocal()) { b.lbDownloadLayout.visibility = View.VISIBLE @@ -373,12 +366,15 @@ class RethinkBlocklistFragment : } else { // remote blocklist // default remote download will happen from rethink-dns list screen // check RethinkListFragment.kt + // if it enters this block, download the blocklist regardless of the timestamp ioCtx { appDownloadManager.downloadRemoteBlocklist( persistentState.remoteBlocklistTimestamp, - isRedownload = false + isRedownload = true ) } + b.lbDownloadProgressRemote.visibility = View.GONE + hasBlocklist() } } } @@ -853,7 +849,7 @@ class RethinkBlocklistFragment : -> if (workInfoList != null && workInfoList.isNotEmpty()) { val workInfo = workInfoList[0] - if (workInfo != null && workInfo.state == WorkInfo.State.SUCCEEDED) { + if (workInfo.state == WorkInfo.State.SUCCEEDED) { Logger.i( Logger.LOG_TAG_DOWNLOAD, "AppDownloadManager Work Manager completed - $FILE_TAG" @@ -861,9 +857,7 @@ class RethinkBlocklistFragment : onDownloadSuccess() workManager.pruneWork() } else if ( - workInfo != null && - (workInfo.state == WorkInfo.State.CANCELLED || - workInfo.state == WorkInfo.State.FAILED) + workInfo.state == WorkInfo.State.CANCELLED || workInfo.state == WorkInfo.State.FAILED ) { onDownloadFail() workManager.pruneWork() diff --git a/app/src/full/java/com/celzero/bravedns/ui/fragment/RethinkLogFragment.kt b/app/src/full/java/com/celzero/bravedns/ui/fragment/RethinkLogFragment.kt index 43d232109..bfc39f2b7 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/fragment/RethinkLogFragment.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/fragment/RethinkLogFragment.kt @@ -15,20 +15,22 @@ limitations under the License. */ package com.celzero.bravedns.ui.fragment +import Logger +import Logger.LOG_TAG_UI +import android.content.Context.INPUT_METHOD_SERVICE import android.os.Bundle import android.view.View +import android.view.inputmethod.InputMethodManager import androidx.appcompat.widget.SearchView import androidx.fragment.app.Fragment -import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import by.kirich1409.viewbindingdelegate.viewBinding import com.celzero.bravedns.R import com.celzero.bravedns.adapter.RethinkLogAdapter import com.celzero.bravedns.database.RethinkLogRepository -import com.celzero.bravedns.databinding.ActivityConnectionTrackerBinding +import com.celzero.bravedns.databinding.FragmentConnectionTrackerBinding import com.celzero.bravedns.service.PersistentState import com.celzero.bravedns.util.Constants import com.celzero.bravedns.util.UIUtils.formatToRelativeTime @@ -41,8 +43,8 @@ import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel class RethinkLogFragment : - Fragment(R.layout.activity_connection_tracker), SearchView.OnQueryTextListener { - private val b by viewBinding(ActivityConnectionTrackerBinding::bind) + Fragment(R.layout.fragment_connection_tracker), SearchView.OnQueryTextListener { + private val b by viewBinding(FragmentConnectionTrackerBinding::bind) private var layoutManager: RecyclerView.LayoutManager? = null private val viewModel: RethinkLogViewModel by viewModel() @@ -51,6 +53,8 @@ class RethinkLogFragment : private val persistentState by inject() companion object { + private const val QUERY_TEXT_DELAY: Long = 1000 + fun newInstance(param: String): RethinkLogFragment { val args = Bundle() args.putString(Constants.SEARCH_QUERY, param) @@ -69,6 +73,19 @@ class RethinkLogFragment : } } + override fun onResume() { + super.onResume() + // fix for #1939, OEM-specific bug, especially on heavily customized Android + // some ROMs kill or freeze the keyboard/IME process to save memory or battery, + // causing SearchView to stop receiving input events + // this is a workaround to restart the IME process + b.connectionSearch.setQuery("", false) + b.connectionSearch.clearFocus() + + val imm = requireContext().getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager + imm.restartInput(b.connectionSearch) + } + private fun initView() { // no need to show filter options for rethink logs @@ -87,14 +104,11 @@ class RethinkLogFragment : layoutManager = LinearLayoutManager(requireContext()) b.recyclerConnection.layoutManager = layoutManager val recyclerAdapter = RethinkLogAdapter(requireContext()) - viewLifecycleOwner.lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.rlogList.observe(viewLifecycleOwner) { it -> - recyclerAdapter.submitData(lifecycle, it) - } - } + viewModel.rlogList.observe(viewLifecycleOwner) { + recyclerAdapter.submitData(lifecycle, it) } b.recyclerConnection.adapter = recyclerAdapter + b.recyclerConnection.layoutAnimation = null setupRecyclerScrollListener() @@ -110,9 +124,17 @@ class RethinkLogFragment : override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { super.onScrolled(recyclerView, dx, dy) - if (recyclerView.getChildAt(0)?.tag == null) return + val firstChild = recyclerView.getChildAt(0) + if (firstChild == null) { + Logger.w(LOG_TAG_UI, "RinRLogs; err; no child views found in recyclerView") + return + } - val tag: Long = recyclerView.getChildAt(0).tag as Long + val tag = firstChild.tag as? Long + if (tag == null) { + Logger.w(LOG_TAG_UI, "RinRLogs; err; tag is null for first child, rv") + return + } b.connectionListScrollHeader.text = formatToRelativeTime(requireContext(), tag) b.connectionListScrollHeader.visibility = View.VISIBLE @@ -129,12 +151,16 @@ class RethinkLogFragment : } override fun onQueryTextSubmit(query: String): Boolean { - viewModel.setFilter(query) + Utilities.delay(QUERY_TEXT_DELAY, lifecycleScope) { + if (this.isAdded) { + viewModel.setFilter(query) + } + } return true } override fun onQueryTextChange(query: String): Boolean { - Utilities.delay(500, lifecycleScope) { + Utilities.delay(QUERY_TEXT_DELAY, lifecycleScope) { if (this.isAdded) { viewModel.setFilter(query) } diff --git a/app/src/full/java/com/celzero/bravedns/ui/fragment/SummaryStatisticsFragment.kt b/app/src/full/java/com/celzero/bravedns/ui/fragment/SummaryStatisticsFragment.kt index ea6ea3588..311531d86 100644 --- a/app/src/full/java/com/celzero/bravedns/ui/fragment/SummaryStatisticsFragment.kt +++ b/app/src/full/java/com/celzero/bravedns/ui/fragment/SummaryStatisticsFragment.kt @@ -19,6 +19,8 @@ import android.content.Intent import android.content.res.ColorStateList import android.os.Bundle import android.view.View +import android.view.animation.Animation +import android.view.animation.RotateAnimation import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import by.kirich1409.viewbindingdelegate.viewBinding @@ -27,7 +29,6 @@ import com.celzero.bravedns.adapter.SummaryStatisticsAdapter import com.celzero.bravedns.data.AppConfig import com.celzero.bravedns.data.DataUsageSummary import com.celzero.bravedns.databinding.FragmentSummaryStatisticsBinding -import com.celzero.bravedns.service.FirewallManager import com.celzero.bravedns.service.PersistentState import com.celzero.bravedns.ui.activity.DetailedStatisticsActivity import com.celzero.bravedns.util.CustomLinearLayoutManager @@ -50,28 +51,40 @@ class SummaryStatisticsFragment : Fragment(R.layout.fragment_summary_statistics) private val persistentState by inject() private var isVpnActive: Boolean = false + private var loadMoreClicked: Boolean = false + + private var contactedDomainsAdapter: SummaryStatisticsAdapter? = null + private var contactedAsnAdapter: SummaryStatisticsAdapter? = null + private var blockedAsnAdapter: SummaryStatisticsAdapter? = null + private var contactedCountriesAdapter: SummaryStatisticsAdapter? = null + + private lateinit var animation: Animation enum class SummaryStatisticsType(val tid: Int) { MOST_CONNECTED_APPS(0), MOST_BLOCKED_APPS(1), - MOST_CONTACTED_DOMAINS(2), - MOST_CONTACTED_COUNTRIES(3), - MOST_BLOCKED_DOMAINS(4), - MOST_CONTACTED_IPS(5), - MOST_BLOCKED_IPS(6), - MOST_BLOCKED_COUNTRIES(7); + MOST_CONNECTED_ASN(2), + MOST_BLOCKED_ASN(3), + MOST_CONTACTED_DOMAINS(4), + MOST_CONTACTED_COUNTRIES(5), + MOST_BLOCKED_DOMAINS(6), + MOST_CONTACTED_IPS(7), + MOST_BLOCKED_IPS(8), + TOP_ACTIVE_CONNS(9); companion object { fun getType(t: Int): SummaryStatisticsType { return when (t) { MOST_CONNECTED_APPS.tid -> MOST_CONNECTED_APPS MOST_BLOCKED_APPS.tid -> MOST_BLOCKED_APPS + MOST_CONNECTED_ASN.tid -> MOST_CONNECTED_ASN + MOST_BLOCKED_ASN.tid -> MOST_BLOCKED_ASN MOST_CONTACTED_DOMAINS.tid -> MOST_CONTACTED_DOMAINS MOST_BLOCKED_DOMAINS.tid -> MOST_BLOCKED_DOMAINS MOST_CONTACTED_COUNTRIES.tid -> MOST_CONTACTED_COUNTRIES - MOST_BLOCKED_COUNTRIES.tid -> MOST_BLOCKED_COUNTRIES MOST_CONTACTED_IPS.tid -> MOST_CONTACTED_IPS MOST_BLOCKED_IPS.tid -> MOST_BLOCKED_IPS + TOP_ACTIVE_CONNS.tid -> TOP_ACTIVE_CONNS // make most contacted apps as default else -> MOST_CONNECTED_APPS } @@ -91,16 +104,19 @@ class SummaryStatisticsFragment : Fragment(R.layout.fragment_summary_statistics) } private fun initView() { + addAnimation() setTabbedViewTxt() highlightToggleBtn() + showTopActiveApps() showAppNetworkActivity() showBlockedApps() - showMostContactedDomain() - showMostBlockedDomains() - showMostContactedIps() - showMostBlockedIps() - showMostContactedCountries() - showMostBlockedCountries() + if (persistentState.downloadIpInfo) { + showMostConnectedASN() + showMostBlockedASN() + } else { + b.fssAsnAllowedLl.visibility = View.GONE + b.fssAsnBlockedLl.visibility = View.GONE + } } private fun setTabbedViewTxt() { @@ -114,8 +130,8 @@ class SummaryStatisticsFragment : Fragment(R.layout.fragment_summary_statistics) // get the tabbed view from the view model and set the toggle button // to the selected one. in case of fragment resume, the recycler view // and the toggle button to be in sync - val timeCategory = viewModel.getTimeCategory().value.toString() - val btn = b.toggleGroup.findViewWithTag(timeCategory) + val tc = viewModel.getTimeCategory().value.toString() + val btn = b.toggleGroup.findViewWithTag(tc) btn.isChecked = true handleTotalUsagesUi() } @@ -185,15 +201,26 @@ class SummaryStatisticsFragment : Fragment(R.layout.fragment_summary_statistics) } private fun initClickListeners() { + b.loadMoreTv.setOnClickListener { + showLoadMoreProgress(!loadMoreClicked) + } b.toggleGroup.addOnButtonCheckedListener(listViewToggleListener) + b.fssActiveAppsChip.setOnClickListener { + openDetailedStatsUi(SummaryStatisticsType.TOP_ACTIVE_CONNS) + } b.fssAppInfoChip.setOnClickListener { openDetailedStatsUi(SummaryStatisticsType.MOST_CONNECTED_APPS) } b.fssAppInfoChipSecond.setOnClickListener { openDetailedStatsUi(SummaryStatisticsType.MOST_BLOCKED_APPS) } - + b.fssAsnChip.setOnClickListener { + openDetailedStatsUi(SummaryStatisticsType.MOST_CONNECTED_ASN) + } + b.fssAsnChipSecond.setOnClickListener { + openDetailedStatsUi(SummaryStatisticsType.MOST_BLOCKED_ASN) + } b.fssDnsLogsChip.setOnClickListener { openDetailedStatsUi(SummaryStatisticsType.MOST_CONTACTED_DOMAINS) } @@ -211,9 +238,6 @@ class SummaryStatisticsFragment : Fragment(R.layout.fragment_summary_statistics) b.fssCountriesLogsChip.setOnClickListener { openDetailedStatsUi(SummaryStatisticsType.MOST_CONTACTED_COUNTRIES) } - b.fssCountriesChipSecond.setOnClickListener { - openDetailedStatsUi(SummaryStatisticsType.MOST_BLOCKED_COUNTRIES) - } } private val listViewToggleListener = @@ -225,11 +249,12 @@ class SummaryStatisticsFragment : Fragment(R.layout.fragment_summary_statistics) val timeCategory = SummaryStatisticsViewModel.TimeCategory.fromValue(tcValue) ?: SummaryStatisticsViewModel.TimeCategory.ONE_HOUR - io { - val isAppBypassed = FirewallManager.isAnyAppBypassesDns() - uiCtx { viewModel.timeCategoryChanged(timeCategory, isAppBypassed) } - handleTotalUsagesUi() - } + viewModel.timeCategoryChanged(timeCategory) + handleTotalUsagesUi() + contactedDomainsAdapter?.setTimeCategory(timeCategory) + contactedCountriesAdapter?.setTimeCategory(timeCategory) + contactedAsnAdapter?.setTimeCategory(timeCategory) + blockedAsnAdapter?.setTimeCategory(timeCategory) return@OnButtonCheckedListener } @@ -252,6 +277,54 @@ class SummaryStatisticsFragment : Fragment(R.layout.fragment_summary_statistics) ) } + private fun handleLoadMore(isClicked: Boolean) { + viewModel.setLoadMoreClicked(isClicked) + if (!isClicked) { + return + } + showMostContactedDomain() + showMostBlockedDomains() + showMostContactedIps() + showMostBlockedIps() + showMostContactedCountries() + } + + private fun showLoadMoreProgress(isClicked: Boolean) { + if (isClicked) { + b.loadMoreTv.isEnabled = false + b.loadProgressBar.animation = animation + b.loadProgressBar.startAnimation(animation) + Utilities.delay(LOAD_MORE_TIMEOUT, lifecycleScope) { + if (this.isAdded) { + b.loadProgressBar.clearAnimation() + b.loadProgressBar.visibility = View.GONE + b.loadMoreLl.visibility = View.GONE + } + } + b.loadProgressBar.visibility = View.VISIBLE + b.loadMoreTv.visibility = View.GONE + } else { + b.loadMoreLl.visibility = View.VISIBLE + b.loadMoreTv.visibility = View.VISIBLE + b.loadProgressBar.visibility = View.GONE + } + loadMoreClicked = isClicked + handleLoadMore(isClicked) + } + + private fun addAnimation() { + animation = + RotateAnimation(ANIMATION_START_DEGREE, + ANIMATION_END_DEGREE, + Animation.RELATIVE_TO_SELF, + ANIMATION_PIVOT_VALUE, + Animation.RELATIVE_TO_SELF, + ANIMATION_PIVOT_VALUE + ) + animation.repeatCount = ANIMATION_REPEAT_COUNT + animation.duration = ANIMATION_DURATION + } + private fun openDetailedStatsUi(type: SummaryStatisticsType) { val mb = b.toggleGroup.checkedButtonId val timeCategory = @@ -270,6 +343,48 @@ class SummaryStatisticsFragment : Fragment(R.layout.fragment_summary_statistics) fun newInstance() = SummaryStatisticsFragment() private const val RECYCLER_ITEM_VIEW_HEIGHT = 480 + private const val ANIMATION_DURATION = 750L + private const val ANIMATION_REPEAT_COUNT = -1 + private const val ANIMATION_PIVOT_VALUE = 0.5f + private const val ANIMATION_START_DEGREE = 0.0f + private const val ANIMATION_END_DEGREE = 360.0f + + private const val LOAD_MORE_TIMEOUT: Long = 1000 + } + + private fun showTopActiveApps() { + b.fssActiveAppsRecyclerView.setHasFixedSize(true) + val layoutManager = CustomLinearLayoutManager(requireContext()) + b.fssActiveAppsRecyclerView.layoutManager = layoutManager + + val recyclerAdapter = + SummaryStatisticsAdapter( + requireContext(), + persistentState, + appConfig, + SummaryStatisticsType.TOP_ACTIVE_CONNS + ) + + viewModel.getTopActiveConns.observe(viewLifecycleOwner) { + recyclerAdapter.submitData(viewLifecycleOwner.lifecycle, it) + } + + recyclerAdapter.addLoadStateListener { + if (it.append.endOfPaginationReached) { + if (recyclerAdapter.itemCount < 1) { + b.fssActiveAppsLl.visibility = View.GONE + } else { + b.fssActiveAppsLl.visibility = View.VISIBLE + } + } else { + b.fssActiveAppsLl.visibility = View.VISIBLE + } + } + + val scale = resources.displayMetrics.density + val pixels = ((RECYCLER_ITEM_VIEW_HEIGHT - 80) * scale + 0.5f) + b.fssActiveAppsRecyclerView.minimumHeight = pixels.toInt() + b.fssActiveAppsRecyclerView.adapter = recyclerAdapter } private fun showAppNetworkActivity() { @@ -282,8 +397,7 @@ class SummaryStatisticsFragment : Fragment(R.layout.fragment_summary_statistics) requireContext(), persistentState, appConfig, - SummaryStatisticsType.MOST_CONNECTED_APPS - ) + SummaryStatisticsType.MOST_CONNECTED_APPS) viewModel.getAllowedAppNetworkActivity.observe(viewLifecycleOwner) { recyclerAdapter.submitData(viewLifecycleOwner.lifecycle, it) @@ -294,7 +408,11 @@ class SummaryStatisticsFragment : Fragment(R.layout.fragment_summary_statistics) if (it.append.endOfPaginationReached) { if (recyclerAdapter.itemCount < 1) { b.fssAppAllowedLl.visibility = View.GONE + } else { + b.fssAppAllowedLl.visibility = View.VISIBLE } + } else { + b.fssAppAllowedLl.visibility = View.VISIBLE } } @@ -325,16 +443,95 @@ class SummaryStatisticsFragment : Fragment(R.layout.fragment_summary_statistics) if (it.append.endOfPaginationReached) { if (recyclerAdapter.itemCount < 1) { b.fssAppBlockedLl.visibility = View.GONE + } else { + b.fssAppBlockedLl.visibility = View.VISIBLE } + } else { + b.fssAppBlockedLl.visibility = View.VISIBLE } } val scale = resources.displayMetrics.density - val pixels = ((RECYCLER_ITEM_VIEW_HEIGHT - 60) * scale + 0.5f) + val pixels = ((RECYCLER_ITEM_VIEW_HEIGHT - 80) * scale + 0.5f) b.fssAppBlockedRecyclerView.minimumHeight = pixels.toInt() b.fssAppBlockedRecyclerView.adapter = recyclerAdapter } + private fun showMostConnectedASN() { + b.fssAsnAllowedRecyclerView.setHasFixedSize(true) + val layoutManager = CustomLinearLayoutManager(requireContext()) + b.fssAsnAllowedRecyclerView.layoutManager = layoutManager + + contactedAsnAdapter = + SummaryStatisticsAdapter( + requireContext(), + persistentState, + appConfig, + SummaryStatisticsType.MOST_CONNECTED_ASN + ) + + + val timeCategory = viewModel.getTimeCategory() + contactedAsnAdapter?.setTimeCategory(timeCategory) + + viewModel.getMostConnectedASN.observe(viewLifecycleOwner) { + contactedAsnAdapter?.submitData(viewLifecycleOwner.lifecycle, it) + } + + contactedAsnAdapter?.addLoadStateListener { + if (it.append.endOfPaginationReached) { + if (contactedAsnAdapter!!.itemCount < 1) { + b.fssAsnAllowedLl.visibility = View.GONE + } else { + b.fssAsnAllowedLl.visibility = View.VISIBLE + } + } else { + b.fssAsnAllowedLl.visibility = View.VISIBLE + } + } + val scale = resources.displayMetrics.density + val pixels = ((RECYCLER_ITEM_VIEW_HEIGHT - 80) * scale + 0.5f) + b.fssAsnAllowedRecyclerView.minimumHeight = pixels.toInt() + b.fssAsnAllowedRecyclerView.adapter = contactedAsnAdapter + } + + private fun showMostBlockedASN() { + b.fssAsnBlockedRecyclerView.setHasFixedSize(true) + val layoutManager = CustomLinearLayoutManager(requireContext()) + b.fssAsnBlockedRecyclerView.layoutManager = layoutManager + + blockedAsnAdapter = + SummaryStatisticsAdapter( + requireContext(), + persistentState, + appConfig, + SummaryStatisticsType.MOST_BLOCKED_ASN + ) + + val timeCategory = viewModel.getTimeCategory() + blockedAsnAdapter?.setTimeCategory(timeCategory) + + viewModel.getMostBlockedASN.observe(viewLifecycleOwner) { + blockedAsnAdapter?.submitData(viewLifecycleOwner.lifecycle, it) + } + + blockedAsnAdapter?.addLoadStateListener { + if (it.append.endOfPaginationReached) { + if (blockedAsnAdapter!!.itemCount < 1) { + b.fssAsnBlockedLl.visibility = View.GONE + } else { + b.fssAsnBlockedLl.visibility = View.VISIBLE + } + } else { + b.fssAsnBlockedLl.visibility = View.VISIBLE + } + } + val scale = resources.displayMetrics.density + val pixels = ((RECYCLER_ITEM_VIEW_HEIGHT - 80) * scale + 0.5f) + b.fssAsnBlockedRecyclerView.minimumHeight = pixels.toInt() + b.fssAsnBlockedRecyclerView.adapter = blockedAsnAdapter + } + private fun showMostContactedDomain() { // if dns is not active then hide the view if (!appConfig.getBraveMode().isDnsActive()) { @@ -346,7 +543,7 @@ class SummaryStatisticsFragment : Fragment(R.layout.fragment_summary_statistics) val layoutManager = CustomLinearLayoutManager(requireContext()) b.fssContactedDomainRecyclerView.layoutManager = layoutManager - val recyclerAdapter = + contactedDomainsAdapter = SummaryStatisticsAdapter( requireContext(), persistentState, @@ -354,21 +551,29 @@ class SummaryStatisticsFragment : Fragment(R.layout.fragment_summary_statistics) SummaryStatisticsType.MOST_CONTACTED_DOMAINS ) - viewModel.getMostContactedDomains.observe(viewLifecycleOwner) { - recyclerAdapter.submitData(viewLifecycleOwner.lifecycle, it) + + val timeCategory = viewModel.getTimeCategory() + contactedDomainsAdapter?.setTimeCategory(timeCategory) + + viewModel.mcd.observe(viewLifecycleOwner) { + contactedDomainsAdapter?.submitData(viewLifecycleOwner.lifecycle, it) } - recyclerAdapter.addLoadStateListener { + contactedDomainsAdapter?.addLoadStateListener { if (it.append.endOfPaginationReached) { - if (recyclerAdapter.itemCount < 1) { + if (contactedDomainsAdapter!!.itemCount < 1) { b.fssDomainAllowedLl.visibility = View.GONE + } else { + b.fssDomainAllowedLl.visibility = View.VISIBLE } + } else { + b.fssDomainAllowedLl.visibility = View.VISIBLE } } val scale = resources.displayMetrics.density - val pixels = ((RECYCLER_ITEM_VIEW_HEIGHT - 60) * scale + 0.5f) + val pixels = ((RECYCLER_ITEM_VIEW_HEIGHT - 80) * scale + 0.5f) b.fssContactedDomainRecyclerView.minimumHeight = pixels.toInt() - b.fssContactedDomainRecyclerView.adapter = recyclerAdapter + b.fssContactedDomainRecyclerView.adapter = contactedDomainsAdapter } private fun showMostBlockedDomains() { @@ -389,7 +594,7 @@ class SummaryStatisticsFragment : Fragment(R.layout.fragment_summary_statistics) SummaryStatisticsType.MOST_BLOCKED_DOMAINS ) - viewModel.getMostBlockedDomains.observe(viewLifecycleOwner) { + viewModel.mbd.observe(viewLifecycleOwner) { recyclerAdapter.submitData(viewLifecycleOwner.lifecycle, it) } @@ -397,11 +602,15 @@ class SummaryStatisticsFragment : Fragment(R.layout.fragment_summary_statistics) if (it.append.endOfPaginationReached) { if (recyclerAdapter.itemCount < 1) { b.fssDomainBlockedLl.visibility = View.GONE + } else { + b.fssDomainBlockedLl.visibility = View.VISIBLE } + } else { + b.fssDomainBlockedLl.visibility = View.VISIBLE } } val scale = resources.displayMetrics.density - val pixels = ((RECYCLER_ITEM_VIEW_HEIGHT - 60) * scale + 0.5f) + val pixels = ((RECYCLER_ITEM_VIEW_HEIGHT - 80) * scale + 0.5f) b.fssBlockedDomainRecyclerView.minimumHeight = pixels.toInt() b.fssBlockedDomainRecyclerView.adapter = recyclerAdapter } @@ -432,50 +641,19 @@ class SummaryStatisticsFragment : Fragment(R.layout.fragment_summary_statistics) if (it.append.endOfPaginationReached) { if (recyclerAdapter.itemCount < 1) { b.fssIpAllowedLl.visibility = View.GONE + } else { + b.fssIpAllowedLl.visibility = View.VISIBLE } + } else { + b.fssIpAllowedLl.visibility = View.VISIBLE } } val scale = resources.displayMetrics.density - val pixels = ((RECYCLER_ITEM_VIEW_HEIGHT - 60) * scale + 0.5f) + val pixels = ((RECYCLER_ITEM_VIEW_HEIGHT - 80) * scale + 0.5f) b.fssContactedIpsRecyclerView.minimumHeight = pixels.toInt() b.fssContactedIpsRecyclerView.adapter = recyclerAdapter } - private fun showMostContactedCountries() { - // if firewall is not active, hide the view - if (!appConfig.getBraveMode().isFirewallActive()) { - b.fssCountriesAllowedLl.visibility = View.GONE - return - } - - b.fssContactedCountriesRecyclerView.setHasFixedSize(true) - val layoutManager = CustomLinearLayoutManager(requireContext()) - b.fssContactedCountriesRecyclerView.layoutManager = layoutManager - - val recyclerAdapter = - SummaryStatisticsAdapter( - requireContext(), - persistentState, - appConfig, - SummaryStatisticsType.MOST_CONTACTED_COUNTRIES - ) - viewModel.getMostContactedCountries.observe(viewLifecycleOwner) { - recyclerAdapter.submitData(viewLifecycleOwner.lifecycle, it) - } - - recyclerAdapter.addLoadStateListener { - if (it.append.endOfPaginationReached) { - if (recyclerAdapter.itemCount < 1) { - b.fssCountriesAllowedLl.visibility = View.GONE - } - } - } - val scale = resources.displayMetrics.density - val pixels = ((RECYCLER_ITEM_VIEW_HEIGHT - 60) * scale + 0.5f) - b.fssContactedCountriesRecyclerView.minimumHeight = pixels.toInt() - b.fssContactedCountriesRecyclerView.adapter = recyclerAdapter - } - private fun showMostBlockedIps() { // if firewall is not active, hide the view if (!appConfig.getBraveMode().isFirewallActive()) { @@ -502,48 +680,56 @@ class SummaryStatisticsFragment : Fragment(R.layout.fragment_summary_statistics) if (it.append.endOfPaginationReached) { if (recyclerAdapter.itemCount < 1) { b.fssIpBlockedLl.visibility = View.GONE + } else { + b.fssIpBlockedLl.visibility = View.VISIBLE } + } else { + b.fssIpBlockedLl.visibility = View.VISIBLE } } val scale = resources.displayMetrics.density - val pixels = ((RECYCLER_ITEM_VIEW_HEIGHT - 60) * scale + 0.5f) + val pixels = ((RECYCLER_ITEM_VIEW_HEIGHT - 80) * scale + 0.5f) b.fssBlockedIpsRecyclerView.minimumHeight = pixels.toInt() b.fssBlockedIpsRecyclerView.adapter = recyclerAdapter } - private fun showMostBlockedCountries() { + private fun showMostContactedCountries() { // if firewall is not active, hide the view if (!appConfig.getBraveMode().isFirewallActive()) { - b.fssCountriesBlockedLl.visibility = View.GONE + b.fssCountriesAllowedLl.visibility = View.GONE return } b.fssContactedCountriesRecyclerView.setHasFixedSize(true) val layoutManager = CustomLinearLayoutManager(requireContext()) - b.fssCountriesBlockedRecyclerView.layoutManager = layoutManager + b.fssContactedCountriesRecyclerView.layoutManager = layoutManager - val recyclerAdapter = + contactedCountriesAdapter = SummaryStatisticsAdapter( requireContext(), persistentState, appConfig, SummaryStatisticsType.MOST_CONTACTED_COUNTRIES ) - viewModel.getMostBlockedCountries.observe(viewLifecycleOwner) { - recyclerAdapter.submitData(viewLifecycleOwner.lifecycle, it) + viewModel.getMostContactedCountries.observe(viewLifecycleOwner) { + contactedCountriesAdapter?.submitData(viewLifecycleOwner.lifecycle, it) } - recyclerAdapter.addLoadStateListener { + contactedCountriesAdapter?.addLoadStateListener { if (it.append.endOfPaginationReached) { - if (recyclerAdapter.itemCount < 1) { - b.fssCountriesBlockedLl.visibility = View.GONE + if (contactedCountriesAdapter!!.itemCount < 1) { + b.fssCountriesAllowedLl.visibility = View.GONE + } else { + b.fssCountriesAllowedLl.visibility = View.VISIBLE } + } else { + b.fssCountriesAllowedLl.visibility = View.VISIBLE } } val scale = resources.displayMetrics.density - val pixels = ((RECYCLER_ITEM_VIEW_HEIGHT - 60) * scale + 0.5f) - b.fssCountriesBlockedRecyclerView.minimumHeight = pixels.toInt() - b.fssCountriesBlockedRecyclerView.adapter = recyclerAdapter + val pixels = ((RECYCLER_ITEM_VIEW_HEIGHT - 80) * scale + 0.5f) + b.fssContactedCountriesRecyclerView.minimumHeight = pixels.toInt() + b.fssContactedCountriesRecyclerView.adapter = contactedCountriesAdapter } private fun io(f: suspend () -> Unit) { diff --git a/app/src/full/java/com/celzero/bravedns/ui/fragment/WgNwStatsFragment.kt b/app/src/full/java/com/celzero/bravedns/ui/fragment/WgNwStatsFragment.kt new file mode 100644 index 000000000..2607454c7 --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/ui/fragment/WgNwStatsFragment.kt @@ -0,0 +1,248 @@ +/* + * Copyright 2024 RethinkDNS and its authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.celzero.bravedns.ui.fragment + +import Logger +import Logger.LOG_TAG_UI +import android.content.res.ColorStateList +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import by.kirich1409.viewbindingdelegate.viewBinding +import com.celzero.bravedns.R +import com.celzero.bravedns.adapter.WgNwStatsAdapter +import com.celzero.bravedns.data.DataUsageSummary +import com.celzero.bravedns.databinding.FragmentDnsCryptListBinding +import com.celzero.bravedns.databinding.FragmentWgNwStatsBinding +import com.celzero.bravedns.service.FirewallManager +import com.celzero.bravedns.service.ProxyManager +import com.celzero.bravedns.service.WireguardManager.INVALID_CONF_ID +import com.celzero.bravedns.ui.activity.NetworkLogsActivity.Companion.RULES_SEARCH_ID_WIREGUARD +import com.celzero.bravedns.util.UIUtils +import com.celzero.bravedns.util.Utilities +import com.celzero.bravedns.viewmodel.SummaryStatisticsViewModel +import com.celzero.bravedns.viewmodel.WgNwActivityViewModel +import com.google.android.material.button.MaterialButton +import com.google.android.material.button.MaterialButtonToggleGroup +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koin.androidx.viewmodel.ext.android.viewModel + +class WgNwStatsFragment : Fragment(R.layout.fragment_wg_nw_stats) { + private val b by viewBinding(FragmentWgNwStatsBinding::bind) + private val viewModel: WgNwActivityViewModel by viewModel() + private lateinit var adapter: WgNwStatsAdapter + private var wgId: String = "" + + companion object { + const val TAG = "WgNwStatsFragment" + const val WG_ID = "wireguardId" + fun newInstance(param: String): WgNwStatsFragment { + val args = Bundle() + args.putString(WG_ID, param) + val fragment = WgNwStatsFragment() + Logger.d(LOG_TAG_UI, "$TAG: newInstance called with param for $WG_ID: $param") + fragment.arguments = args + return fragment + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + Logger.v(LOG_TAG_UI, "$TAG: onViewCreated") + if (arguments != null) { + wgId = requireArguments().getString(WG_ID, "") + // remove the search id from the wg id + if (wgId.contains(RULES_SEARCH_ID_WIREGUARD)) { + wgId = wgId.substringAfter(RULES_SEARCH_ID_WIREGUARD) + } + } + + if (wgId.isEmpty() || !wgId.startsWith(ProxyManager.ID_WG_BASE)) { + Logger.i(LOG_TAG_UI, "$TAG: invalid wg id: $wgId") + showErrorDialog() + return + } + + initView() + } + + private fun initView() { + setTabbedViewTxt() + highlightToggleBtn() + setRecyclerView() + setClickListeners() + handleTotalUsagesUi() + } + + private fun setClickListeners() { + b.toggleGroup.addOnButtonCheckedListener(listViewToggleListener) + } + + private fun setTabbedViewTxt() { + b.tbRecentToggleBtn.text = getString(R.string.ci_desc, "1", getString(R.string.lbl_hour)) + b.tbDailyToggleBtn.text = getString(R.string.ci_desc, "24", getString(R.string.lbl_hour)) + b.tbWeeklyToggleBtn.text = getString(R.string.ci_desc, "7", getString(R.string.lbl_day)) + } + + private fun setRecyclerView() { + adapter = WgNwStatsAdapter(requireContext()) + b.statsRecyclerView.layoutManager = LinearLayoutManager(requireContext()) + b.statsRecyclerView.adapter = adapter + + viewModel.setWgId(wgId) + viewModel.wgAppNwActivity.observe(viewLifecycleOwner) { + adapter.submitData(viewLifecycleOwner.lifecycle, it) + } + + adapter.addLoadStateListener { loadState -> + val isEmpty = adapter.itemCount < 1 + if (loadState.append.endOfPaginationReached && isEmpty) { + b.tbStatsCard.visibility = View.GONE + //b.toggleGroup.visibility = View.GONE + b.tbLogsDisabledTv.visibility = View.VISIBLE + viewModel.wgAppNwActivity.removeObservers(this) + } else { + b.tbLogsDisabledTv.visibility = View.GONE + b.tbStatsCard.visibility = View.VISIBLE + //b.toggleGroup.visibility = View.VISIBLE + } + } + } + + private fun highlightToggleBtn() { + val timeCategory = "0" // default is 1 hours, "0" tag is 1 hours + val btn = b.toggleGroup.findViewWithTag(timeCategory) + btn.isChecked = true + selectToggleBtnUi(btn) + } + + private val listViewToggleListener = + MaterialButtonToggleGroup.OnButtonCheckedListener { _, checkedId, isChecked -> + val mb: MaterialButton = b.toggleGroup.findViewById(checkedId) + if (isChecked) { + selectToggleBtnUi(mb) + val tcValue = (mb.tag as String).toIntOrNull() ?: 0 + val timeCategory = + WgNwActivityViewModel.TimeCategory.fromValue(tcValue) + ?: WgNwActivityViewModel.TimeCategory.ONE_HOUR + Logger.d(LOG_TAG_UI, "$TAG: time category changed to $timeCategory") + viewModel.timeCategoryChanged(timeCategory) + handleTotalUsagesUi() + return@OnButtonCheckedListener + } + + unselectToggleBtnUi(mb) + } + + private fun handleTotalUsagesUi() { + io { + val totalUsage = viewModel.totalUsage(wgId) + uiCtx { setTotalUsagesUi(totalUsage) } + } + } + + private fun setTotalUsagesUi(dataUsage: DataUsageSummary) { + val unmeteredUsage = (dataUsage.totalDownload + dataUsage.totalUpload) + val totalUsage = unmeteredUsage + dataUsage.meteredDataUsage + + b.fssUnmeteredDataUsage.text = + getString( + R.string.two_argument_colon, + getString(R.string.ada_app_unmetered), + Utilities.humanReadableByteCount(unmeteredUsage, true) + ) + b.fssMeteredDataUsage.text = + getString( + R.string.two_argument_colon, + getString(R.string.ada_app_metered), + Utilities.humanReadableByteCount(dataUsage.meteredDataUsage, true) + ) + b.fssTotalDataUsage.text = + getString( + R.string.two_argument_colon, + getString(R.string.lbl_overall), + Utilities.humanReadableByteCount(totalUsage, true) + ) + b.fssMeteredDataUsage.setCompoundDrawablesWithIntrinsicBounds( + R.drawable.dot_accent, + 0, + 0, + 0 + ) + + // set the alpha for the drawable + val alphaValue = 128 // half-transparent + val drawable = b.fssMeteredDataUsage.compoundDrawables[0] // drawableLeft + drawable?.mutate()?.alpha = alphaValue + + // set the progress bar + val ump = calculatePercentage(unmeteredUsage, totalUsage) // unmetered percentage + val mp = calculatePercentage(dataUsage.meteredDataUsage, totalUsage) // metered percentage + val secondaryVal = ump + mp + + b.fssProgressBar.max = secondaryVal + b.fssProgressBar.progress = ump + b.fssProgressBar.secondaryProgress = secondaryVal + } + + private fun calculatePercentage(value: Long, maxValue: Long): Int { + if (maxValue == 0L) return 0 + + return (value * 100 / maxValue).toInt() + } + + private fun selectToggleBtnUi(mb: MaterialButton) { + mb.backgroundTintList = + ColorStateList.valueOf( + UIUtils.fetchToggleBtnColors(requireContext(), R.color.accentGood) + ) + mb.setTextColor(UIUtils.fetchColor(requireContext(), R.attr.homeScreenHeaderTextColor)) + } + + private fun unselectToggleBtnUi(mb: MaterialButton) { + mb.setTextColor(UIUtils.fetchColor(requireContext(), R.attr.primaryTextColor)) + mb.backgroundTintList = + ColorStateList.valueOf( + UIUtils.fetchToggleBtnColors(requireContext(), R.color.defaultToggleBtnBg) + ) + } + + private fun showErrorDialog() { + // Show error dialog + val dialog = MaterialAlertDialogBuilder(requireContext()) + .setTitle(getString(R.string.invalid_wireguard_dialog_title)) + .setMessage(getString(R.string.invalid_wireguard_dialog_desc)) + .setPositiveButton(R.string.dns_info_positive) { _, _ -> + requireActivity().onBackPressedDispatcher + } + .setCancelable(false) + .create() + dialog.show() + } + + private fun io(f: suspend () -> Unit) { + this.lifecycleScope.launch(Dispatchers.IO) { f() } + } + + private suspend fun uiCtx(f: suspend () -> Unit) { + withContext(Dispatchers.Main) { f() } + } +} diff --git a/app/src/full/java/com/celzero/bravedns/util/BackgroundAccessibilityService.kt b/app/src/full/java/com/celzero/bravedns/util/BackgroundAccessibilityService.kt index ca83ac169..fa2ff80cd 100644 --- a/app/src/full/java/com/celzero/bravedns/util/BackgroundAccessibilityService.kt +++ b/app/src/full/java/com/celzero/bravedns/util/BackgroundAccessibilityService.kt @@ -16,29 +16,285 @@ package com.celzero.bravedns.util import Logger +import Logger.LOG_TAG_APP_OPS import Logger.LOG_TAG_FIREWALL +import android.Manifest import android.accessibilityservice.AccessibilityService +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent import android.content.Intent import android.content.pm.PackageManager +import android.graphics.Color +import android.graphics.PixelFormat +import android.hardware.camera2.CameraManager +import android.media.AudioManager +import android.media.AudioRecordingConfiguration +import android.os.Build import android.text.TextUtils +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.WindowManager import android.view.accessibility.AccessibilityEvent +import androidx.annotation.RequiresApi +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.celzero.bravedns.R +import com.celzero.bravedns.databinding.MicCamAccessIndicatorBinding import com.celzero.bravedns.service.FirewallManager import com.celzero.bravedns.service.PersistentState import com.celzero.bravedns.service.VpnController +import com.celzero.bravedns.ui.HomeScreenActivity +import com.celzero.bravedns.ui.activity.AppLockActivity +import com.celzero.bravedns.util.Utilities.isAtleastN +import com.celzero.bravedns.util.Utilities.isAtleastP import com.celzero.bravedns.util.Utilities.isAtleastT import org.koin.android.ext.android.inject import org.koin.core.component.KoinComponent +// cam and mic access is still not working as expected, need to test it +// commented out the ui code for now, will enable it once the feature is working +// for cam and mic access ref: github.com/NitishGadangi/Privacy-Indicator-App/blob/master/app/src/main/java/com/nitish/privacyindicator +// see: developer.android.com/guide/topics/media/camera#kotlin +// see: developer.android.com/guide/topics/media/audio-capture class BackgroundAccessibilityService : AccessibilityService(), KoinComponent { private val persistentState by inject() + private lateinit var windowManager: WindowManager + private lateinit var b: MicCamAccessIndicatorBinding + private lateinit var lp: WindowManager.LayoutParams + + private var cameraManager: CameraManager? = null + private var audioManager: AudioManager? = null + private var micCallback: AudioManager.AudioRecordingCallback? = null + private var cameraCallback: CameraManager.AvailabilityCallback? = null + + private var cameraOn = false + private var micOn = false + private var notifManager: NotificationManagerCompat? = null + private var notifBuilder: NotificationCompat.Builder? = null + private var possibleUid: Int? = null + private var possibleAppName: String? = null + private val notificationID = 7897 + + companion object { + private const val NOTIF_CHANNEL_ID = "MIC_CAM_ACCESS" + } + + override fun onServiceConnected() { + if (isAtleastN() && persistentState.micCamAccess) { + overlay() + callBacks() + } + } + + @RequiresApi(Build.VERSION_CODES.N) + private fun callBacks() { + if (!persistentState.micCamAccess) return + + try { + if (cameraManager == null) cameraManager = + getSystemService(CAMERA_SERVICE) as CameraManager + cameraManager!!.registerAvailabilityCallback(getCameraCallback(), null) + + if (audioManager == null) audioManager = getSystemService(AUDIO_SERVICE) as AudioManager + audioManager!!.registerAudioRecordingCallback(getMicCallback(), null) + } catch (e: Exception) { + Logger.e(LOG_TAG_FIREWALL, "Error in registering callbacks: ${e.message}") + } + } + + private fun getCameraCallback(): CameraManager.AvailabilityCallback { + cameraCallback = + object : CameraManager.AvailabilityCallback() { + override fun onCameraAvailable(cameraId: String) { + super.onCameraAvailable(cameraId) + cameraOn = false + hideCam() + dismissNotification() + } + + override fun onCameraUnavailable(cameraId: String) { + super.onCameraUnavailable(cameraId) + cameraOn = true + showCam() + showNotification() + } + } + return cameraCallback as CameraManager.AvailabilityCallback + } + + private fun getMicCallback(): AudioManager.AudioRecordingCallback { + micCallback = + @RequiresApi(Build.VERSION_CODES.N) + object : AudioManager.AudioRecordingCallback() { + override fun onRecordingConfigChanged(configs: List) { + if (configs.isNotEmpty()) { + micOn = true + showMic() + showNotification() + } else { + micOn = false + hideMic() + dismissNotification() + } + } + } + return micCallback as AudioManager.AudioRecordingCallback + } + + private fun overlay() { + windowManager = getSystemService(WINDOW_SERVICE) as WindowManager + lp = WindowManager.LayoutParams() + lp.type = WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY + lp.format = PixelFormat.TRANSLUCENT + lp.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE + lp.width = WindowManager.LayoutParams.WRAP_CONTENT + lp.height = WindowManager.LayoutParams.WRAP_CONTENT + lp.gravity = layoutGravity + b = MicCamAccessIndicatorBinding.inflate(LayoutInflater.from(this)) + windowManager.addView(b.root, lp) + } + + private fun showMic() { + Logger.e(LOG_TAG_APP_OPS, "Mic is being used: ${persistentState.micCamAccess}") + if (persistentState.micCamAccess) { + updateIndicatorProperties() + b.ivMic.visibility = View.VISIBLE + } + } + + private fun hideMic() { + b.ivMic.visibility = View.GONE + } + + private fun showCam() { + Logger.e(LOG_TAG_APP_OPS, "Camera is being used: ${persistentState.micCamAccess}") + if (persistentState.micCamAccess) { + updateIndicatorProperties() + b.ivCam.visibility = View.VISIBLE + } + } + + private fun hideCam() { + b.ivCam.visibility = View.GONE + } + + + private val layoutGravity: Int + get() = Gravity.TOP or Gravity.END + + private fun updateIndicatorProperties() { + updateLayoutGravity() + } + + private fun updateLayoutGravity() { + lp.gravity = layoutGravity + windowManager.updateViewLayout(b.root, lp) + } + + private fun setupNotification() { + createNotificationChannel() + notifBuilder = + NotificationCompat.Builder(applicationContext, NOTIF_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification_icon) + .setContentTitle(notificationTitle) + .setContentText(notificationDescription) + .setContentIntent(getPendingIntent()) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + notifManager = NotificationManagerCompat.from(applicationContext) + } + + private val notificationTitle: String + get() { + if (cameraOn && micOn) return "Your Camera and Mic is ON" + if (cameraOn) return "Your Camera is ON" + return if (micOn) "Your MIC is ON" else "Your Camera or Mic is ON" + } + + private val notificationDescription: String + get() { + if (cameraOn && micOn) + return "A third-party app($possibleAppName) is using your Camera and Microphone" + if (cameraOn) return "A third-party app($possibleAppName) is using your Camera" + return if (micOn) "A third-party app($possibleAppName) is using your Microphone" + else "A third-party app($possibleAppName) is using your Camera or Microphone" + } + + private fun showNotification() { + setupNotification() + if (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != + PackageManager.PERMISSION_GRANTED) { + // notification permission request and handling are done in the HomeScreenFragment + // so no need to handle it here + return + } + if (notifManager != null) + notifManager!!.notify(notificationID, notifBuilder!!.build()) + } + + private fun dismissNotification() { + if (cameraOn || micOn) { + showNotification() + } else { + if (notifManager != null) notifManager!!.cancel(notificationID) + } + } + + private fun getPendingIntent(): PendingIntent { + val intent = Intent(applicationContext, AppLockActivity::class.java) + return PendingIntent.getActivity( + applicationContext, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + } + + private fun createNotificationChannel() { + val notificationChannel = "Notifications for Camera and Mic access" + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val importance = NotificationManager.IMPORTANCE_LOW + val channel = + NotificationChannel(NOTIF_CHANNEL_ID, notificationChannel, importance) + val description = "Notification for Camera and Mic access" + channel.description = description + channel.lightColor = Color.RED + val notificationManager = + applicationContext.getSystemService(NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + } override fun onInterrupt() { Logger.w(LOG_TAG_FIREWALL, "BackgroundAccessibilityService Interrupted") } - override fun onRebind(intent: Intent?) { - super.onRebind(intent) + private fun unRegisterCameraCallBack() { + try { + if (cameraManager != null && cameraCallback != null) { + cameraManager!!.unregisterAvailabilityCallback(cameraCallback!!) + } + } catch (e: Exception) { + Logger.e(LOG_TAG_FIREWALL, "Error in unregistering camera callback: ${e.message}") + } + } + + private fun unRegisterMicCallback() { + try { + if (isAtleastN()) { + if (audioManager != null && micCallback != null) { + audioManager!!.unregisterAudioRecordingCallback(micCallback!!) + } + } + } catch (e: Exception) { + Logger.e(LOG_TAG_FIREWALL, "Error in unregistering mic callback: ${e.message}") + } + } + + override fun onDestroy() { + unRegisterCameraCallBack() + unRegisterMicCallback() + super.onDestroy() } override fun onAccessibilityEvent(event: AccessibilityEvent) { @@ -112,14 +368,14 @@ class BackgroundAccessibilityService : AccessibilityService(), KoinComponent { // no need ot handle the events when the vpn is not running if (!VpnController.isOn()) return - if (!persistentState.getBlockAppWhenBackground()) return + if (!persistentState.getBlockAppWhenBackground() && !persistentState.micCamAccess) return val latestTrackedPackage = getEventPackageName(event) if (latestTrackedPackage.isNullOrEmpty()) return val hasContentChanged = - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) { + if (isAtleastP()) { event.eventType == AccessibilityEvent.CONTENT_CHANGE_TYPE_PANE_DISAPPEARED || event.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED } else { @@ -127,14 +383,17 @@ class BackgroundAccessibilityService : AccessibilityService(), KoinComponent { } Logger.d( LOG_TAG_FIREWALL, - "onAccessibilityEvent: ${event.packageName}, ${event.eventType}, $hasContentChanged" - ) + "onAccessibilityEvent: ${event.packageName}, ${event.eventType}, $hasContentChanged") if (!hasContentChanged) return // If the package received is Rethink, do nothing. if (event.packageName == this.packageName) return + possibleUid = getEventUid(latestTrackedPackage) ?: return + + possibleAppName = Utilities.getPackageInfoForUid(this, possibleUid!!)?.firstOrNull() + // https://stackoverflow.com/a/27642535 // top window is launcher? try revoke queued up permissions // FIXME: Figure out a fool-proof way to determine is launcher visible @@ -142,7 +401,7 @@ class BackgroundAccessibilityService : AccessibilityService(), KoinComponent { if (isPackageLauncher(latestTrackedPackage)) { FirewallManager.untrackForegroundApps() } else { - val uid = getEventUid(latestTrackedPackage) ?: return + val uid = possibleUid ?: return FirewallManager.trackForegroundApp(uid) } } @@ -183,9 +442,7 @@ class BackgroundAccessibilityService : AccessibilityService(), KoinComponent { .resolveActivity( intent, PackageManager.ResolveInfoFlags.of( - PackageManager.MATCH_DEFAULT_ONLY.toLong() - ) - ) + PackageManager.MATCH_DEFAULT_ONLY.toLong())) ?.activityInfo ?.packageName } else { diff --git a/app/src/full/java/com/celzero/bravedns/util/UIUtils.kt b/app/src/full/java/com/celzero/bravedns/util/UIUtils.kt index 956a9169d..a9ea619d0 100644 --- a/app/src/full/java/com/celzero/bravedns/util/UIUtils.kt +++ b/app/src/full/java/com/celzero/bravedns/util/UIUtils.kt @@ -23,6 +23,7 @@ import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.content.res.TypedArray +import android.graphics.Paint import android.net.Uri import android.os.Build import android.provider.Settings @@ -31,15 +32,17 @@ import android.text.Spanned import android.text.format.DateUtils import android.util.TypedValue import android.widget.Toast +import androidx.appcompat.widget.AppCompatTextView import androidx.core.content.getSystemService import androidx.core.net.toUri import androidx.core.text.HtmlCompat -import backend.Backend +import com.celzero.firestack.backend.Backend import com.celzero.bravedns.R import com.celzero.bravedns.database.DnsLog import com.celzero.bravedns.glide.FavIconDownloader import com.celzero.bravedns.net.doh.Transaction import com.celzero.bravedns.service.DnsLogTracker +import com.celzero.firestack.backend.NetStat import java.util.Calendar import java.util.Date import java.util.regex.Matcher @@ -81,7 +84,7 @@ object UIUtils { } } - fun getProxyStatusStringRes(statusId: Long): Int { + fun getProxyStatusStringRes(statusId: Long?): Int { return when (statusId) { Backend.TUP -> { R.string.lbl_starting @@ -107,6 +110,15 @@ object UIUtils { } } + enum class ProxyStatus(val id: Long) { + TOK(Backend.TOK), + TUP(Backend.TUP), + TZZ(Backend.TZZ), + TNT(Backend.TNT), + TKO(Backend.TKO), + END(Backend.END) + } + fun formatToRelativeTime(context: Context, timestamp: Long): String { val now = System.currentTimeMillis() return if (DateUtils.isToday(timestamp)) { @@ -217,7 +229,7 @@ object UIUtils { fun sendEmailIntent(context: Context) { val intent = Intent(Intent.ACTION_SENDTO).apply { - data = Uri.parse(context.getString(R.string.about_mail_to_string)) + data = context.getString(R.string.about_mail_to_string).toUri() putExtra(Intent.EXTRA_EMAIL, arrayOf(context.getString(R.string.about_mail_to))) putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.about_mail_subject)) } @@ -233,6 +245,7 @@ object UIUtils { try { val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) intent.data = Uri.fromParts("package", packageName, null) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK context.startActivity(intent) } catch (e: Exception) { // ActivityNotFoundException | NullPointerException Logger.w(Logger.LOG_TAG_FIREWALL, "Failure calling app info: ${e.message}", e) @@ -254,44 +267,64 @@ object UIUtils { fun fetchToggleBtnColors(context: Context, attr: Int): Int { val attributeFetch = - if (attr == R.color.firewallNoRuleToggleBtnTxt) { - R.attr.firewallNoRuleToggleBtnTxt - } else if (attr == R.color.firewallNoRuleToggleBtnBg) { - R.attr.firewallNoRuleToggleBtnBg - } else if (attr == R.color.firewallBlockToggleBtnTxt) { - R.attr.firewallBlockToggleBtnTxt - } else if (attr == R.color.firewallBlockToggleBtnBg) { - R.attr.firewallBlockToggleBtnBg - } else if (attr == R.color.firewallWhiteListToggleBtnTxt) { - R.attr.firewallWhiteListToggleBtnTxt - } else if (attr == R.color.firewallWhiteListToggleBtnBg) { - R.attr.firewallWhiteListToggleBtnBg - } else if (attr == R.color.firewallExcludeToggleBtnBg) { - R.attr.firewallExcludeToggleBtnBg - } else if (attr == R.color.firewallExcludeToggleBtnTxt) { - R.attr.firewallExcludeToggleBtnTxt - } else if (attr == R.color.defaultToggleBtnBg) { - R.attr.defaultToggleBtnBg - } else if (attr == R.color.defaultToggleBtnTxt) { - R.attr.defaultToggleBtnTxt - } else if (attr == R.color.accentGood) { - R.attr.accentGood - } else if (attr == R.color.accentBad) { - R.attr.accentBad - } else if (attr == R.color.chipBgNeutral) { - R.attr.chipBgColorNeutral - } else if (attr == R.color.chipBgNegative) { - R.attr.chipBgColorNegative - } else if (attr == R.color.chipBgPositive) { - R.attr.chipBgColorPositive - } else if (attr == R.color.chipTextNeutral) { - R.attr.chipTextNeutral - } else if (attr == R.color.chipTextNegative) { - R.attr.chipTextNegative - } else if (attr == R.color.chipTextPositive) { - R.attr.chipTextPositive - } else { - R.attr.chipBgColorPositive + when (attr) { + R.color.firewallNoRuleToggleBtnTxt -> { + R.attr.firewallNoRuleToggleBtnTxt + } + R.color.firewallNoRuleToggleBtnBg -> { + R.attr.firewallNoRuleToggleBtnBg + } + R.color.firewallBlockToggleBtnTxt -> { + R.attr.firewallBlockToggleBtnTxt + } + R.color.firewallBlockToggleBtnBg -> { + R.attr.firewallBlockToggleBtnBg + } + R.color.firewallWhiteListToggleBtnTxt -> { + R.attr.firewallWhiteListToggleBtnTxt + } + R.color.firewallWhiteListToggleBtnBg -> { + R.attr.firewallWhiteListToggleBtnBg + } + R.color.firewallExcludeToggleBtnBg -> { + R.attr.firewallExcludeToggleBtnBg + } + R.color.firewallExcludeToggleBtnTxt -> { + R.attr.firewallExcludeToggleBtnTxt + } + R.color.defaultToggleBtnBg -> { + R.attr.defaultToggleBtnBg + } + R.color.defaultToggleBtnTxt -> { + R.attr.defaultToggleBtnTxt + } + R.color.accentGood -> { + R.attr.accentGood + } + R.color.accentBad -> { + R.attr.accentBad + } + R.color.chipBgNeutral -> { + R.attr.chipBgColorNeutral + } + R.color.chipBgNegative -> { + R.attr.chipBgColorNegative + } + R.color.chipBgPositive -> { + R.attr.chipBgColorPositive + } + R.color.chipTextNeutral -> { + R.attr.chipTextNeutral + } + R.color.chipTextNegative -> { + R.attr.chipTextNegative + } + R.color.chipTextPositive -> { + R.attr.chipTextPositive + } + else -> { + R.attr.chipBgColorPositive + } } return fetchColor(context, attributeFetch) } @@ -301,7 +334,7 @@ object UIUtils { if (isDgaDomain(dnsLog.queryStr)) return - Logger.d(Logger.LOG_TAG_UI, "Glide - fetchFavIcon():${dnsLog.queryStr}") + Logger.d(LOG_TAG_UI, "Glide - fetchFavIcon():${dnsLog.queryStr}") // fetch fav icon in background using glide FavIconDownloader(context, dnsLog.queryStr).run() @@ -632,4 +665,30 @@ object UIUtils { return result.toString().trim() } + + fun formatNetStat(stat: NetStat?): String? { + if (stat == null) return null + + val ip = stat.ip()?.toString() + val udp = stat.udp()?.toString() + val tcp = stat.tcp()?.toString() + val fwd = stat.fwd()?.toString() + val icmp = stat.icmp()?.toString() + val nic = stat.nic()?.toString() + val rdnsInfo = stat.rdnsinfo()?.toString() + val nicInfo = stat.nicinfo()?.toString() + val go = stat.go()?.toString() + val tun = stat.tun()?.toString() + + var stats = nic + nicInfo + fwd + ip + icmp + tcp + udp + rdnsInfo + go + tun + stats = stats.replace("{", "\n") + stats = stats.replace("}", "\n\n") + stats = stats.replace(",", "\n") + + return stats + } + + fun AppCompatTextView.underline() { + paintFlags = paintFlags or Paint.UNDERLINE_TEXT_FLAG + } } diff --git a/app/src/full/java/com/celzero/bravedns/viewmodel/AlertsViewModel.kt b/app/src/full/java/com/celzero/bravedns/viewmodel/AlertsViewModel.kt index 579df197e..8b8ef43d6 100644 --- a/app/src/full/java/com/celzero/bravedns/viewmodel/AlertsViewModel.kt +++ b/app/src/full/java/com/celzero/bravedns/viewmodel/AlertsViewModel.kt @@ -39,26 +39,4 @@ class AlertsViewModel( fromTime.value = System.currentTimeMillis() - 1 * 60 * 60 * 1000L toTime.value = System.currentTimeMillis() } - - fun getBlockedIpLogList(): LiveData> { - val fromTime = fromTime.value ?: 0L - val toTime = toTime.value ?: 0L - return connectionTrackerDao.getBlockedIpLogList(fromTime, toTime) - } - - fun getBlockedAppsLogList(): LiveData> { - val fromTime = fromTime.value ?: 0L - val toTime = toTime.value ?: 0L - return connectionTrackerDao.getBlockedAppLogList(fromTime, toTime) - } - - fun getBlockedDnsLogList(isAppBypassed: Boolean): LiveData> { - val fromTime = fromTime.value ?: 0L - val toTime = toTime.value ?: 0L - return if (isAppBypassed) { - connectionTrackerDao.getBlockedDomainsList(fromTime, toTime) - } else { - dnsLogDao.getBlockedDnsLogList(fromTime, toTime) - } - } } diff --git a/app/src/full/java/com/celzero/bravedns/viewmodel/AppConnectionsViewModel.kt b/app/src/full/java/com/celzero/bravedns/viewmodel/AppConnectionsViewModel.kt index 72deb940f..42c8dd610 100644 --- a/app/src/full/java/com/celzero/bravedns/viewmodel/AppConnectionsViewModel.kt +++ b/app/src/full/java/com/celzero/bravedns/viewmodel/AppConnectionsViewModel.kt @@ -27,15 +27,26 @@ import androidx.paging.cachedIn import androidx.paging.liveData import com.celzero.bravedns.data.AppConnection import com.celzero.bravedns.database.ConnectionTrackerDAO +import com.celzero.bravedns.database.RethinkLogDao +import com.celzero.bravedns.database.StatsSummaryDao +import com.celzero.bravedns.service.VpnController import com.celzero.bravedns.util.Constants -class AppConnectionsViewModel(private val nwlogDao: ConnectionTrackerDAO) : ViewModel() { +class AppConnectionsViewModel( + private val nwlogDao: ConnectionTrackerDAO, + private val rinrDao: RethinkLogDao, + private val statsDao: StatsSummaryDao +) : ViewModel() { private var ipFilter: MutableLiveData = MutableLiveData() private var domainFilter: MutableLiveData = MutableLiveData() + private var asnFilter: MutableLiveData = MutableLiveData() + private var activeConnsFilter: MutableLiveData = MutableLiveData() + private var uid: Int = Constants.INVALID_UID private val pagingConfig: PagingConfig - private var timeCategory: TimeCategory = TimeCategory.ONE_HOUR + private var timeCategory: TimeCategory = TimeCategory.SEVEN_DAYS private var startTime: MutableLiveData = MutableLiveData() + var filterQuery: String = "" companion object { private const val ONE_HOUR_MILLIS = 1 * 60 * 60 * 1000L @@ -56,7 +67,8 @@ class AppConnectionsViewModel(private val nwlogDao: ConnectionTrackerDAO) : View init { ipFilter.value = "" domainFilter.value = "" - + asnFilter.value = "" + activeConnsFilter.value = "" pagingConfig = PagingConfig( enablePlaceholders = true, @@ -90,16 +102,52 @@ class AppConnectionsViewModel(private val nwlogDao: ConnectionTrackerDAO) : View domainFilter.value = "" } else { ipFilter.value = "" + asnFilter.value = "" } } enum class FilterType { IP, - DOMAIN + DOMAIN, + ASN, + ACTIVE_CONNECTIONS } val appIpLogs = ipFilter.switchMap { input -> fetchIpLogs(uid, input) } - val appDomainLogs = domainFilter.switchMap { input -> fetchAppDomainLogs(uid, input) } + val appDomainLogs = domainFilter.switchMap { input -> + fetchAppDomainLogs(uid, input) + } + val asnLogs = asnFilter.switchMap { input -> + fetchAllAsnLogs(uid, input) + } + val activeConnections = activeConnsFilter.switchMap { input -> + fetchAllActiveConnections(uid, input) + } + + val rinrIpLogs = ipFilter.switchMap { input -> fetchRinrIpLogs(input) } + val rinrDomainLogs = domainFilter.switchMap { input -> fetchRinrDomainLogs(input) } + + private fun fetchRinrIpLogs(input: String): LiveData> { + val to = getStartTime() + return if (input.isEmpty()) { + Pager(pagingConfig) { rinrDao.getIpLogs(to) } + } else { + Pager(pagingConfig) { rinrDao.getIpLogsFiltered(to, "%$input%") } + } + .liveData + .cachedIn(viewModelScope) + } + + private fun fetchRinrDomainLogs(input: String): LiveData> { + val to = getStartTime() + return if (input.isEmpty()) { + Pager(pagingConfig) { rinrDao.getDomainLogs(to) } + } else { + Pager(pagingConfig) { rinrDao.getDomainLogsFiltered(to, "%$input%") } + } + .liveData + .cachedIn(viewModelScope) + } private fun fetchIpLogs(uid: Int, input: String): LiveData> { val to = getStartTime() @@ -114,30 +162,87 @@ class AppConnectionsViewModel(private val nwlogDao: ConnectionTrackerDAO) : View private fun fetchAppDomainLogs(uid: Int, input: String): LiveData> { val to = getStartTime() - return if (input.isEmpty()) { - Pager(pagingConfig) { nwlogDao.getAppDomainLogs(uid, to) } - } else { - Pager(pagingConfig) { nwlogDao.getAppDomainLogsFiltered(uid, to, "%$input%") } + return Pager(pagingConfig) { + if (input.isEmpty()) { + statsDao.getAllDomainsByUid(uid, to) + } else { + statsDao.getAllDomainsByUid(uid, to, "%$input%") + } } .liveData .cachedIn(viewModelScope) } - private fun getStartTime(): Long { - return startTime.value ?: (System.currentTimeMillis() - ONE_HOUR_MILLIS) + fun fetchTopActiveConnections(uid: Int, uptime: Long): LiveData> { + val to = System.currentTimeMillis() - uptime + return Pager(pagingConfig) { statsDao.getTopActiveConns(uid, to) } + .liveData + .cachedIn(viewModelScope) } - fun getConnectionsCount(uid: Int): LiveData { - return nwlogDao.getAppConnectionsCount(uid) + private fun fetchAllActiveConnections(uid: Int, input: String): LiveData> { + val to = System.currentTimeMillis() - VpnController.uptimeMs() + val query = "%$input%" + return Pager(pagingConfig) { statsDao.getAllActiveConns(uid, to, query) } + .liveData + .cachedIn(viewModelScope) } - fun getAppDomainConnectionsCount(uid: Int): LiveData { - return nwlogDao.getAppDomainConnectionsCount(uid) + private fun fetchAllAsnLogs(uid: Int, input: String): LiveData> { + val to = getStartTime() + val query = "%$input%" + return Pager(pagingConfig) { statsDao.getAllAsnLogs(uid, to, query) } + .liveData + .cachedIn(viewModelScope) + } + + fun deleteLogs(uid: Int) { + // delete based on the time category + when (timeCategory) { + TimeCategory.ONE_HOUR -> { + nwlogDao.clearLogsByTime(uid, System.currentTimeMillis() - ONE_HOUR_MILLIS) + } + + TimeCategory.TWENTY_FOUR_HOUR -> { + nwlogDao.clearLogsByTime(uid, System.currentTimeMillis() - ONE_DAY_MILLIS) + } + + TimeCategory.SEVEN_DAYS -> { + nwlogDao.clearLogsByUid(uid) // similar to clearing logs for uid + } + } + } + + private fun getStartTime(): Long { + return startTime.value ?: (System.currentTimeMillis() - ONE_WEEK_MILLIS) } fun getDomainLogsLimited(uid: Int): LiveData> { val to = System.currentTimeMillis() - ONE_WEEK_MILLIS - return Pager(pagingConfig) { nwlogDao.getAppDomainLogsLimited(uid, to) } + return Pager(pagingConfig) { + statsDao.getMostDomainsByUid(uid, to) + } + .liveData + .cachedIn(viewModelScope) + } + + fun getRethinkDomainLogsLimited(): LiveData> { + val to = System.currentTimeMillis() - ONE_WEEK_MILLIS + return Pager(pagingConfig) { rinrDao.getDomainLogsLimited(to) } + .liveData + .cachedIn(viewModelScope) + } + + fun getRethinkIpLogsLimited(): LiveData> { + val to = System.currentTimeMillis() - ONE_WEEK_MILLIS + return Pager(pagingConfig) { rinrDao.getIpLogsLimited(to) } + .liveData + .cachedIn(viewModelScope) + } + + fun getAsnLogsLimited(uid: Int): LiveData> { + val to = System.currentTimeMillis() - ONE_WEEK_MILLIS + return Pager(pagingConfig) { statsDao.getAsnLogsLimited(uid, to) } .liveData .cachedIn(viewModelScope) } @@ -150,10 +255,23 @@ class AppConnectionsViewModel(private val nwlogDao: ConnectionTrackerDAO) : View } fun setFilter(input: String, filterType: FilterType) { - if (filterType == FilterType.IP) { - this.ipFilter.postValue(input) - } else { - this.domainFilter.postValue(input) + filterQuery = input + when (filterType) { + FilterType.IP -> { + ipFilter.value = input + } + + FilterType.DOMAIN -> { + domainFilter.value = input + } + + FilterType.ASN -> { + asnFilter.value = input + } + + FilterType.ACTIVE_CONNECTIONS -> { + activeConnsFilter.value = input + } } } diff --git a/app/src/full/java/com/celzero/bravedns/viewmodel/AppInfoViewModel.kt b/app/src/full/java/com/celzero/bravedns/viewmodel/AppInfoViewModel.kt index c3edbb05d..b85c95530 100644 --- a/app/src/full/java/com/celzero/bravedns/viewmodel/AppInfoViewModel.kt +++ b/app/src/full/java/com/celzero/bravedns/viewmodel/AppInfoViewModel.kt @@ -56,13 +56,24 @@ class AppInfoViewModel(private val appInfoDAO: AppInfoDAO) : ViewModel() { } } + private fun getBypassProxyFilter(): Set { + val filter = firewallFilter.getFilter() + val bypassFilter = setOf(2, 7) + if (filter == bypassFilter) { + return setOf(1) + } + return setOf() // empty set (as query uses or condition) + } + private fun allApps(searchString: String): LiveData> { + val includeProxyBypass = getBypassProxyFilter() return if (category.isEmpty()) { Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { appInfoDAO.getAppInfos( "%$searchString%", firewallFilter.getFilter(), - firewallFilter.getConnectionStatusFilter() + firewallFilter.getConnectionStatusFilter(), + includeProxyBypass ) } .liveData @@ -73,7 +84,8 @@ class AppInfoViewModel(private val appInfoDAO: AppInfoDAO) : ViewModel() { "%$searchString%", category, firewallFilter.getFilter(), - firewallFilter.getConnectionStatusFilter() + firewallFilter.getConnectionStatusFilter(), + includeProxyBypass ) } .liveData @@ -82,12 +94,14 @@ class AppInfoViewModel(private val appInfoDAO: AppInfoDAO) : ViewModel() { } private fun installedApps(search: String): LiveData> { + val includeProxyBypass = getBypassProxyFilter() return if (category.isEmpty()) { Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { appInfoDAO.getInstalledApps( "%$search%", firewallFilter.getFilter(), - firewallFilter.getConnectionStatusFilter() + firewallFilter.getConnectionStatusFilter(), + includeProxyBypass ) } .liveData @@ -98,7 +112,8 @@ class AppInfoViewModel(private val appInfoDAO: AppInfoDAO) : ViewModel() { "%$search%", category, firewallFilter.getFilter(), - firewallFilter.getConnectionStatusFilter() + firewallFilter.getConnectionStatusFilter(), + includeProxyBypass ) } .liveData @@ -107,12 +122,14 @@ class AppInfoViewModel(private val appInfoDAO: AppInfoDAO) : ViewModel() { } private fun systemApps(search: String): LiveData> { + val includeProxyBypass = getBypassProxyFilter() return if (category.isEmpty()) { Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { appInfoDAO.getSystemApps( "%$search%", firewallFilter.getFilter(), - firewallFilter.getConnectionStatusFilter() + firewallFilter.getConnectionStatusFilter(), + includeProxyBypass ) } .liveData @@ -123,7 +140,8 @@ class AppInfoViewModel(private val appInfoDAO: AppInfoDAO) : ViewModel() { "%$search%", category, firewallFilter.getFilter(), - firewallFilter.getConnectionStatusFilter() + firewallFilter.getConnectionStatusFilter(), + includeProxyBypass ) } .liveData diff --git a/app/src/full/java/com/celzero/bravedns/viewmodel/ConnectionTrackerViewModel.kt b/app/src/full/java/com/celzero/bravedns/viewmodel/ConnectionTrackerViewModel.kt index 90593c4de..7442d7439 100644 --- a/app/src/full/java/com/celzero/bravedns/viewmodel/ConnectionTrackerViewModel.kt +++ b/app/src/full/java/com/celzero/bravedns/viewmodel/ConnectionTrackerViewModel.kt @@ -29,11 +29,15 @@ import com.celzero.bravedns.database.ConnectionTracker import com.celzero.bravedns.database.ConnectionTrackerDAO import com.celzero.bravedns.ui.fragment.ConnectionTrackerFragment import com.celzero.bravedns.util.Constants.Companion.LIVEDATA_PAGE_SIZE +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch class ConnectionTrackerViewModel(private val connectionTrackerDAO: ConnectionTrackerDAO) : ViewModel() { - private var filterString: MutableLiveData = MutableLiveData() + private val _filterString = MutableLiveData() + private var filterString: LiveData = _filterString private var filterRules: MutableSet = mutableSetOf() private var filterType: TopLevelFilter = TopLevelFilter.ALL @@ -46,7 +50,7 @@ class ConnectionTrackerViewModel(private val connectionTrackerDAO: ConnectionTra private val pagingConfig: PagingConfig init { - filterString.value = "" + _filterString.value = "" pagingConfig = PagingConfig( enablePlaceholders = true, @@ -60,14 +64,28 @@ class ConnectionTrackerViewModel(private val connectionTrackerDAO: ConnectionTra val connectionTrackerList = filterString.switchMap { input -> fetchNetworkLogs(input) } + private fun setFilterWithDebounce(searchString: String) { + viewModelScope.launch { + debounceFilter(searchString) + } + } + + private var debounceJob: Job? = null + private fun debounceFilter(searchString: String) { + debounceJob?.cancel() + debounceJob = viewModelScope.launch { + delay(300) // 300ms debounce delay + _filterString.value = searchString + } + } + fun setFilter(searchString: String, filter: Set, type: TopLevelFilter) { filterRules.clear() filterRules.addAll(filter) filterType = type - if (searchString.isNotBlank()) filterString.value = searchString - else filterString.value = "" + setFilterWithDebounce(searchString) } private fun fetchNetworkLogs(input: String): LiveData> { diff --git a/app/src/full/java/com/celzero/bravedns/viewmodel/ConsoleLogViewModel.kt b/app/src/full/java/com/celzero/bravedns/viewmodel/ConsoleLogViewModel.kt new file mode 100644 index 000000000..50cb6d91a --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/viewmodel/ConsoleLogViewModel.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2024 RethinkDNS and its authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.celzero.bravedns.viewmodel + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.switchMap +import androidx.lifecycle.viewModelScope +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.liveData +import com.celzero.bravedns.database.ConsoleLog +import com.celzero.bravedns.database.ConsoleLogDAO +import com.celzero.bravedns.util.Constants + +class ConsoleLogViewModel(private val dao: ConsoleLogDAO) : ViewModel() { + private var filter: MutableLiveData = MutableLiveData() + private var logLevel: Long = Logger.LoggerLevel.ERROR.id + init { + filter.postValue("") + } + + val logs = filter.switchMap { input: String -> getLogs(input) } + + private fun getLogs(filter: String): LiveData> { + val query = "%$filter%" + return Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { dao.getLogs(query) } + .liveData + .cachedIn(viewModelScope) + } + + suspend fun sinceTime(): Long { + return dao.sinceTime() + } + + fun setLogLevel(level: Long) { + + logLevel = level + } + + fun setFilter(filter: String) { + this.filter.postValue(filter) + } +} diff --git a/app/src/full/java/com/celzero/bravedns/viewmodel/DetailedStatisticsViewModel.kt b/app/src/full/java/com/celzero/bravedns/viewmodel/DetailedStatisticsViewModel.kt index 1933b8f05..9d6936fd4 100644 --- a/app/src/full/java/com/celzero/bravedns/viewmodel/DetailedStatisticsViewModel.kt +++ b/app/src/full/java/com/celzero/bravedns/viewmodel/DetailedStatisticsViewModel.kt @@ -23,51 +23,56 @@ import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn import androidx.paging.liveData -import com.celzero.bravedns.data.AppConfig import com.celzero.bravedns.database.ConnectionTrackerDAO -import com.celzero.bravedns.database.DnsLogDAO +import com.celzero.bravedns.database.StatsSummaryDao +import com.celzero.bravedns.service.VpnController import com.celzero.bravedns.ui.fragment.SummaryStatisticsFragment import com.celzero.bravedns.util.Constants class DetailedStatisticsViewModel( private val connectionTrackerDAO: ConnectionTrackerDAO, - private val dnsLogDAO: DnsLogDAO, - appConfig: AppConfig + private val statsDao: StatsSummaryDao ) : ViewModel() { + private var allActiveConns: MutableLiveData = MutableLiveData() private var allowedNetworkActivity: MutableLiveData = MutableLiveData() private var blockedNetworkActivity: MutableLiveData = MutableLiveData() + private var allowedAsn: MutableLiveData = MutableLiveData() + private var blockedAsn: MutableLiveData = MutableLiveData() private var allowedDomains: MutableLiveData = MutableLiveData() private var blockedDomains: MutableLiveData = MutableLiveData() private var allowedIps: MutableLiveData = MutableLiveData() private var blockedIps: MutableLiveData = MutableLiveData() private var allowedCountries: MutableLiveData = MutableLiveData() - private var blockedCountries: MutableLiveData = MutableLiveData() private var startTime: MutableLiveData = MutableLiveData() companion object { private const val ONE_HOUR_MILLIS = 1 * 60 * 60 * 1000L private const val ONE_DAY_MILLIS = 24 * ONE_HOUR_MILLIS private const val ONE_WEEK_MILLIS = 7 * ONE_DAY_MILLIS - private const val IS_APP_BYPASSED = "true" } - fun setData(type: SummaryStatisticsFragment.SummaryStatisticsType, isAppBypassed: Boolean) { + fun setData(type: SummaryStatisticsFragment.SummaryStatisticsType) { when (type) { + SummaryStatisticsFragment.SummaryStatisticsType.TOP_ACTIVE_CONNS -> { + allActiveConns.value = VpnController.uptimeMs() + } SummaryStatisticsFragment.SummaryStatisticsType.MOST_CONNECTED_APPS -> { allowedNetworkActivity.value = "" } SummaryStatisticsFragment.SummaryStatisticsType.MOST_BLOCKED_APPS -> { blockedNetworkActivity.value = "" } + SummaryStatisticsFragment.SummaryStatisticsType.MOST_CONNECTED_ASN -> { + allowedAsn.value = "" + } + SummaryStatisticsFragment.SummaryStatisticsType.MOST_BLOCKED_ASN -> { + blockedAsn.value = "" + } SummaryStatisticsFragment.SummaryStatisticsType.MOST_CONTACTED_DOMAINS -> { allowedDomains.value = "" } SummaryStatisticsFragment.SummaryStatisticsType.MOST_BLOCKED_DOMAINS -> { - if (isAppBypassed) { - blockedDomains.postValue(IS_APP_BYPASSED) - } else { - blockedDomains.postValue("") - } + blockedDomains.value = "" } SummaryStatisticsFragment.SummaryStatisticsType.MOST_CONTACTED_IPS -> { allowedIps.value = "" @@ -78,9 +83,6 @@ class DetailedStatisticsViewModel( SummaryStatisticsFragment.SummaryStatisticsType.MOST_CONTACTED_COUNTRIES -> { allowedCountries.value = "" } - SummaryStatisticsFragment.SummaryStatisticsType.MOST_BLOCKED_COUNTRIES -> { - blockedCountries.value = "" - } } } @@ -98,59 +100,70 @@ class DetailedStatisticsViewModel( } } + val getAllActiveConns = + allActiveConns.switchMap { it -> + val to = System.currentTimeMillis() - it + Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { + statsDao.getAllActiveConns(to) + } + .liveData + .cachedIn(viewModelScope) + } + val getAllAllowedAppNetworkActivity = allowedNetworkActivity.switchMap { _ -> Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { val to = startTime.value ?: 0L - connectionTrackerDAO.getAllAllowedAppNetworkActivity(to) + statsDao.getAllAllowedApps(to) } .liveData .cachedIn(viewModelScope) } - val getAllBlockedAppNetworkActivity = - blockedNetworkActivity.switchMap { _ -> + val getAllAllowedAsn = + allowedAsn.switchMap { _ -> Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { val to = startTime.value ?: 0L - connectionTrackerDAO.getAllBlockedAppNetworkActivity(to) + statsDao.getAllConnectedASN(to) } .liveData .cachedIn(viewModelScope) } - val getAllContactedDomains = - allowedDomains.switchMap { _ -> + val getAllBlockedAsn = + blockedAsn.switchMap { _ -> Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { val to = startTime.value ?: 0L - if (appConfig.getBraveMode().isDnsMode()) { - dnsLogDAO.getAllContactedDomains(to) - } else { - connectionTrackerDAO.getAllContactedDomains(to) - } + statsDao.getAllBlockedASN(to) } .liveData .cachedIn(viewModelScope) } - val getAllBlockedDomains = - blockedDomains.switchMap { isAppBypassed -> + val getAllBlockedAppNetworkActivity = + blockedNetworkActivity.switchMap { _ -> Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { val to = startTime.value ?: 0L - if (appConfig.getBraveMode().isDnsMode()) { - dnsLogDAO.getAllBlockedDomains(to) - } else { - // if any app bypasses the dns, then the decision made in flow() call - if (isAppBypassed.isNotEmpty()) { - connectionTrackerDAO.getAllBlockedDomains(to) - } else { - dnsLogDAO.getAllBlockedDomains(to) - } - } + statsDao.getAllBlockedApps(to) } .liveData .cachedIn(viewModelScope) } + val getAllBlockedDomains = blockedDomains.switchMap { + Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { + val to = startTime.value ?: 0L + statsDao.getAllBlockedDomains(to) + }.liveData.cachedIn(viewModelScope) + } + + val getAllContactedDomains = allowedDomains.switchMap { + Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { + val to = startTime.value ?: 0L + statsDao.getAllContactedDomains(to) + }.liveData.cachedIn(viewModelScope) + } + val getAllContactedIps = allowedIps.switchMap { _ -> Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { @@ -175,17 +188,7 @@ class DetailedStatisticsViewModel( allowedCountries.switchMap { _ -> Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { val to = startTime.value ?: 0L - connectionTrackerDAO.getAllContactedCountries(to) - } - .liveData - .cachedIn(viewModelScope) - } - - val getAllBlockedCountries = - blockedCountries.switchMap { _ -> - Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { - val to = startTime.value ?: 0L - connectionTrackerDAO.getAllBlockedCountries(to) + statsDao.getAllContactedCountries(to) } .liveData .cachedIn(viewModelScope) diff --git a/app/src/full/java/com/celzero/bravedns/viewmodel/DomainConnectionsViewModel.kt b/app/src/full/java/com/celzero/bravedns/viewmodel/DomainConnectionsViewModel.kt new file mode 100644 index 000000000..53eda3653 --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/viewmodel/DomainConnectionsViewModel.kt @@ -0,0 +1,122 @@ +/* + * Copyright 2024 RethinkDNS and its authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.celzero.bravedns.viewmodel + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.switchMap +import androidx.lifecycle.viewModelScope +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.cachedIn +import androidx.paging.liveData +import com.celzero.bravedns.database.StatsSummaryDao +import com.celzero.bravedns.util.Constants + +class DomainConnectionsViewModel(private val statsDao: StatsSummaryDao) : ViewModel() { + private var domains: MutableLiveData = MutableLiveData() + private var asn: MutableLiveData = MutableLiveData() + private var flag: MutableLiveData = MutableLiveData() + private var timeCategory: TimeCategory = TimeCategory.ONE_HOUR + private var startTime: MutableLiveData = MutableLiveData() + private var isBlocked: Boolean = false + + companion object { + private const val ONE_HOUR_MILLIS = 1 * 60 * 60 * 1000L + private const val ONE_DAY_MILLIS = 24 * ONE_HOUR_MILLIS + private const val ONE_WEEK_MILLIS = 7 * ONE_DAY_MILLIS + } + + enum class TimeCategory(val value: Int) { + ONE_HOUR(0), + TWENTY_FOUR_HOUR(1), + SEVEN_DAYS(2); + + companion object { + fun fromValue(value: Int) = entries.firstOrNull { it.value == value } + } + } + + init { + // set from and to time to current and 1 hr before + startTime.value = System.currentTimeMillis() - ONE_HOUR_MILLIS + domains.postValue("") + asn.postValue("") + flag.postValue("") + } + + fun setDomain(domain: String) { + domains.postValue(domain) + } + + fun setFlag(flag: String) { + this.flag.postValue(flag) + } + + fun setAsn(asn: String, isBlocked: Boolean) { + this.asn.postValue(asn) + this.isBlocked = isBlocked + } + + fun timeCategoryChanged(tc: TimeCategory) { + timeCategory = tc + when (tc) { + TimeCategory.ONE_HOUR -> { + startTime.value = System.currentTimeMillis() - ONE_HOUR_MILLIS + } + TimeCategory.TWENTY_FOUR_HOUR -> { + startTime.value = System.currentTimeMillis() - ONE_DAY_MILLIS + } + TimeCategory.SEVEN_DAYS -> { + startTime.value = System.currentTimeMillis() - ONE_WEEK_MILLIS + } + } + asn.value = "" + flag.value = "" + domains.value = "" + } + + val domainConnectionList = domains.switchMap { input -> + fetchDomainConnections(input) + } + + val flagConnectionList = flag.switchMap { input -> + fetchFlagConnections(input) + } + + val asnConnectionList = asn.switchMap { input -> + fetchAsnConnections(input) + } + + private fun fetchDomainConnections(input: String) = + Pager(PagingConfig(pageSize = Constants.LIVEDATA_PAGE_SIZE)) { + statsDao.getDomainDetails(input, startTime.value!!) + }.liveData.cachedIn(viewModelScope) + + private fun fetchFlagConnections(input: String) = + Pager(PagingConfig(pageSize = Constants.LIVEDATA_PAGE_SIZE)) { + statsDao.getFlagDetails(input, startTime.value!!) + }.liveData.cachedIn(viewModelScope) + + private fun fetchAsnConnections(input: String) = + Pager(PagingConfig(pageSize = Constants.LIVEDATA_PAGE_SIZE)) { + if (isBlocked) { + statsDao.getAsnBlockedDetails(input, startTime.value!!) + } else { + statsDao.getAsnDetails(input, startTime.value!!) + } + }.liveData.cachedIn(viewModelScope) +} diff --git a/app/src/full/java/com/celzero/bravedns/viewmodel/SummaryStatisticsViewModel.kt b/app/src/full/java/com/celzero/bravedns/viewmodel/SummaryStatisticsViewModel.kt index da01137f9..d51724940 100644 --- a/app/src/full/java/com/celzero/bravedns/viewmodel/SummaryStatisticsViewModel.kt +++ b/app/src/full/java/com/celzero/bravedns/viewmodel/SummaryStatisticsViewModel.kt @@ -23,30 +23,31 @@ import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn import androidx.paging.liveData -import com.celzero.bravedns.data.AppConfig import com.celzero.bravedns.data.DataUsageSummary import com.celzero.bravedns.database.ConnectionTracker import com.celzero.bravedns.database.ConnectionTrackerDAO -import com.celzero.bravedns.database.DnsLogDAO +import com.celzero.bravedns.database.StatsSummaryDao +import com.celzero.bravedns.service.VpnController import com.celzero.bravedns.util.Constants class SummaryStatisticsViewModel( private val connectionTrackerDAO: ConnectionTrackerDAO, - private val dnsLogDAO: DnsLogDAO, - private val appConfig: AppConfig + private val statsDao: StatsSummaryDao ) : ViewModel() { + private var topActiveConns: MutableLiveData = MutableLiveData() private var networkActivity: MutableLiveData = MutableLiveData() + private var asn: MutableLiveData = MutableLiveData() private var countryActivities: MutableLiveData = MutableLiveData() private var domains: MutableLiveData = MutableLiveData() private var ips: MutableLiveData = MutableLiveData() private var timeCategory: TimeCategory = TimeCategory.ONE_HOUR private var startTime: MutableLiveData = MutableLiveData() + private var loadMoreClicked: Boolean = false companion object { private const val ONE_HOUR_MILLIS = 1 * 60 * 60 * 1000L private const val ONE_DAY_MILLIS = 24 * ONE_HOUR_MILLIS private const val ONE_WEEK_MILLIS = 7 * ONE_DAY_MILLIS - private const val IS_APP_BYPASSED = "true" } enum class TimeCategory(val value: Int) { @@ -62,17 +63,24 @@ class SummaryStatisticsViewModel( init { // set from and to time to current and 1 hr before startTime.value = System.currentTimeMillis() - ONE_HOUR_MILLIS + topActiveConns.value = VpnController.uptimeMs() networkActivity.value = "" - domains.postValue("") - countryActivities.value = "" - ips.value = "" + asn.value = "" } fun getTimeCategory(): TimeCategory { return timeCategory } - fun timeCategoryChanged(tc: TimeCategory, isAppBypassed: Boolean) { + fun setLoadMoreClicked(b: Boolean) { + loadMoreClicked = b + // initialise the live data to trigger the switchMap + domains.value = "" + countryActivities.value = "" + ips.value = "" + } + + fun timeCategoryChanged(tc: TimeCategory) { timeCategory = tc when (tc) { TimeCategory.ONE_HOUR -> { @@ -86,21 +94,30 @@ class SummaryStatisticsViewModel( } } networkActivity.value = "" - if (isAppBypassed) { - domains.postValue(IS_APP_BYPASSED) - } else { - domains.postValue("") + asn.value = "" + if (loadMoreClicked) { + countryActivities.value = "" + ips.value = "" + domains.value = "" } - countryActivities.value = "" - ips.value = "" } + val getTopActiveConns = + topActiveConns.switchMap { it -> + val to = System.currentTimeMillis() - it + Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { + statsDao.getTopActiveConns(to) + } + .liveData + .cachedIn(viewModelScope) + } + val getAllowedAppNetworkActivity = networkActivity.switchMap { _ -> Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { // use dnsQuery as appName val to = startTime.value ?: 0L - connectionTrackerDAO.getAllowedAppNetworkActivity(to) + statsDao.getMostAllowedApps(to) } .liveData .cachedIn(viewModelScope) @@ -111,82 +128,73 @@ class SummaryStatisticsViewModel( Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { // use dnsQuery as appName val to = startTime.value ?: 0L - connectionTrackerDAO.getBlockedAppNetworkActivity(to) + statsDao.getMostBlockedApps(to) } .liveData .cachedIn(viewModelScope) } - val getMostContactedDomains = - domains.switchMap { _ -> + val getMostConnectedASN = + asn.switchMap { _ -> Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { - if (appConfig.getBraveMode().isDnsMode()) { - val to = startTime.value ?: 0L - dnsLogDAO.getMostContactedDomains(to) - } else { - val to = startTime.value ?: 0L - connectionTrackerDAO.getMostContactedDomains(to) - } + val to = startTime.value ?: 0L + statsDao.getMostConnectedASN(to) } .liveData .cachedIn(viewModelScope) } - val getMostBlockedDomains = - domains.switchMap { isAppBypassed -> + val getMostBlockedASN = + asn.switchMap { _ -> Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { - if (appConfig.getBraveMode().isDnsMode()) { - val to = startTime.value ?: 0L - dnsLogDAO.getMostBlockedDomains(to) - } else { - // if any app bypasses the dns, then the decision made in flow() call - val to = startTime.value ?: 0L - if (isAppBypassed.isNotEmpty()) { - connectionTrackerDAO.getMostBlockedDomains(to) - } else { - dnsLogDAO.getMostBlockedDomains(to) - } - } + val to = startTime.value ?: 0L + statsDao.getMostBlockedASN(to) } .liveData .cachedIn(viewModelScope) } - val getMostContactedIps = - ips.switchMap { _ -> - Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { - val to = startTime.value ?: 0L - connectionTrackerDAO.getMostContactedIps(to) - } - .liveData - .cachedIn(viewModelScope) + val mbd = domains.switchMap { + Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { + val to = startTime.value ?: 0L + statsDao.getMostBlockedDomains(to) } + .liveData + .cachedIn(viewModelScope) + } - val getMostBlockedIps = - ips.switchMap { _ -> - Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { - val to = startTime.value ?: 0L - connectionTrackerDAO.getMostBlockedIps(to) - } - .liveData - .cachedIn(viewModelScope) + val mcd = domains.switchMap { + Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { + val to = startTime.value ?: 0L + statsDao.getMostContactedDomains(to) } + .liveData + .cachedIn(viewModelScope) + } - val getMostContactedCountries = - countryActivities.switchMap { _ -> - Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { - val to = startTime.value ?: 0L - connectionTrackerDAO.getMostContactedCountries(to) - } - .liveData - .cachedIn(viewModelScope) + val getMostContactedIps = ips.switchMap { _ -> + Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { + val to = startTime.value ?: 0L + connectionTrackerDAO.getMostContactedIps(to) } + .liveData + .cachedIn(viewModelScope) + } - val getMostBlockedCountries = + val getMostBlockedIps = ips.switchMap { _ -> + Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { + val to = startTime.value ?: 0L + connectionTrackerDAO.getMostBlockedIps(to) + } + .liveData + .cachedIn(viewModelScope) + } + + val getMostContactedCountries = countryActivities.switchMap { _ -> Pager(PagingConfig(Constants.LIVEDATA_PAGE_SIZE)) { val to = startTime.value ?: 0L - connectionTrackerDAO.getMostBlockedCountries(to) + statsDao.getMostContactedCountries(to) } .liveData .cachedIn(viewModelScope) diff --git a/app/src/full/java/com/celzero/bravedns/viewmodel/ViewModelModule.kt b/app/src/full/java/com/celzero/bravedns/viewmodel/ViewModelModule.kt index a1caedf18..55eb9471c 100644 --- a/app/src/full/java/com/celzero/bravedns/viewmodel/ViewModelModule.kt +++ b/app/src/full/java/com/celzero/bravedns/viewmodel/ViewModelModule.kt @@ -33,9 +33,9 @@ object ViewModelModule { viewModel { AppCustomIpViewModel(get()) } viewModel { RethinkRemoteFileTagViewModel(get()) } viewModel { RethinkLocalFileTagViewModel(get()) } - viewModel { AppConnectionsViewModel(get()) } - viewModel { SummaryStatisticsViewModel(get(), get(), get()) } - viewModel { DetailedStatisticsViewModel(get(), get(), get()) } + viewModel { AppConnectionsViewModel(get(), get(), get()) } + viewModel { SummaryStatisticsViewModel(get(), get()) } + viewModel { DetailedStatisticsViewModel(get(), get()) } viewModel { LocalBlocklistPacksMapViewModel(get()) } viewModel { RemoteBlocklistPacksMapViewModel(get()) } viewModel { ProxyAppsMappingViewModel(get()) } @@ -44,6 +44,9 @@ object ViewModelModule { viewModel { ODoHEndpointViewModel(get()) } viewModel { RethinkLogViewModel(get()) } viewModel { AlertsViewModel(get(), get()) } + viewModel { ConsoleLogViewModel(get()) } + viewModel { DomainConnectionsViewModel(get()) } + viewModel { WgNwActivityViewModel(get()) } } val modules = listOf(viewModelModule) diff --git a/app/src/full/java/com/celzero/bravedns/viewmodel/WgNwActivityViewModel.kt b/app/src/full/java/com/celzero/bravedns/viewmodel/WgNwActivityViewModel.kt new file mode 100644 index 000000000..9c9899ca0 --- /dev/null +++ b/app/src/full/java/com/celzero/bravedns/viewmodel/WgNwActivityViewModel.kt @@ -0,0 +1,108 @@ +/* + * Copyright 2024 RethinkDNS and its authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.celzero.bravedns.viewmodel + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.switchMap +import androidx.lifecycle.viewModelScope +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.liveData +import com.celzero.bravedns.data.AppConnection +import com.celzero.bravedns.data.DataUsageSummary +import com.celzero.bravedns.database.ConnectionTracker +import com.celzero.bravedns.database.ConnectionTrackerDAO +import com.celzero.bravedns.util.Constants.Companion.LIVEDATA_PAGE_SIZE + +class WgNwActivityViewModel(private val dao: ConnectionTrackerDAO) : ViewModel() { + + private var startTime: MutableLiveData = MutableLiveData() + private var networkActivity: MutableLiveData = MutableLiveData() + + private var wgId: String = "" + private var timeCategory: TimeCategory = TimeCategory.ONE_HOUR + + companion object { + private const val ONE_HOUR_MILLIS = 1 * 60 * 60 * 1000L + private const val ONE_DAY_MILLIS = 24 * ONE_HOUR_MILLIS + private const val ONE_WEEK_MILLIS = 7 * ONE_DAY_MILLIS + } + + init { + // set from and to time to current and 1 hr before + startTime.value = System.currentTimeMillis() - ONE_HOUR_MILLIS + networkActivity.value = "" + } + + enum class TimeCategory(val value: Int) { + ONE_HOUR(0), + TWENTY_FOUR_HOUR(1), + SEVEN_DAYS(2); + + companion object { + fun fromValue(value: Int) = entries.firstOrNull { it.value == value } + } + } + + private val pagingConfig: PagingConfig = PagingConfig( + enablePlaceholders = true, + prefetchDistance = 3, + initialLoadSize = LIVEDATA_PAGE_SIZE * 2, + maxSize = LIVEDATA_PAGE_SIZE * 3, + pageSize = LIVEDATA_PAGE_SIZE * 2, + jumpThreshold = 5 + ) + + fun timeCategoryChanged(tc: TimeCategory) { + timeCategory = tc + when (tc) { + TimeCategory.ONE_HOUR -> { + startTime.value = + System.currentTimeMillis() - ONE_HOUR_MILLIS + } + + TimeCategory.TWENTY_FOUR_HOUR -> { + startTime.value = System.currentTimeMillis() - ONE_DAY_MILLIS + } + + TimeCategory.SEVEN_DAYS -> { + startTime.value = System.currentTimeMillis() - ONE_WEEK_MILLIS + } + } + networkActivity.value = "" + } + + fun setWgId(wgId: String) { + this.wgId = wgId + } + + val wgAppNwActivity: LiveData> = networkActivity.switchMap { _ -> + Pager(pagingConfig) { + val to = startTime.value ?: 0L + dao.getWgAppNetworkActivity(wgId, to) + }.liveData.cachedIn(viewModelScope) + } + + fun totalUsage(wgId: String): DataUsageSummary { + val to = startTime.value ?: 0L + return dao.getTotalUsagesByWgId(to, ConnectionTracker.ConnType.METERED.value, wgId) + } +} + diff --git a/app/src/full/res/layout/activity_advanced_setting.xml b/app/src/full/res/layout/activity_advanced_setting.xml new file mode 100644 index 000000000..4729c024e --- /dev/null +++ b/app/src/full/res/layout/activity_advanced_setting.xml @@ -0,0 +1,814 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/full/res/layout/activity_app_details.xml b/app/src/full/res/layout/activity_app_details.xml index d0ef2fb80..0254b9158 100644 --- a/app/src/full/res/layout/activity_app_details.xml +++ b/app/src/full/res/layout/activity_app_details.xml @@ -38,27 +38,49 @@ android:layout_toEndOf="@id/aad_app_detail_icon" android:orientation="vertical"> - - + + - @@ -76,10 +98,10 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="5dp" + android:layout_marginBottom="10dp" android:minWidth="24dp" android:minHeight="24dp" - android:src="@drawable/brave_mode_info" /> - + android:src="@drawable/ic_info_white" /> @@ -101,7 +123,7 @@ android:layout_marginEnd="5dp" android:orientation="vertical" android:paddingTop="5dp" - android:paddingBottom="5dp"> + android:paddingBottom="15dp"> - - - - - - - - - + + + - - - - + + + + + + + + + + + + + + + + + + + + + @@ -214,7 +217,7 @@ - + app:fastScrollVerticalTrackDrawable="@drawable/fast_scroll_line_drawable" + app:fastScrollAutoHide="true" + app:fastScrollAutoHideDelay="1500" + app:fastScrollEnableThumbInactiveColor="true" + app:fastScrollThumbColor="?attr/chipColorBgNormal" + app:fastScrollThumbEnabled="true" + app:fastScrollThumbInactiveColor="?attr/chipColorBgNormal" + app:fastScrollTrackColor="?attr/background" /> diff --git a/app/src/full/res/layout/activity_app_lock.xml b/app/src/full/res/layout/activity_app_lock.xml new file mode 100644 index 000000000..7e906a515 --- /dev/null +++ b/app/src/full/res/layout/activity_app_lock.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/full/res/layout/activity_console_log.xml b/app/src/full/res/layout/activity_console_log.xml new file mode 100644 index 000000000..1dbb9fdb0 --- /dev/null +++ b/app/src/full/res/layout/activity_console_log.xml @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/full/res/layout/activity_home_screen.xml b/app/src/full/res/layout/activity_home_screen.xml index e4f41bfe0..6fd26d3c9 100644 --- a/app/src/full/res/layout/activity_home_screen.xml +++ b/app/src/full/res/layout/activity_home_screen.xml @@ -1,5 +1,5 @@ - - - + diff --git a/app/src/full/res/layout/activity_misc_settings.xml b/app/src/full/res/layout/activity_misc_settings.xml index ad7650629..fec10d6a0 100644 --- a/app/src/full/res/layout/activity_misc_settings.xml +++ b/app/src/full/res/layout/activity_misc_settings.xml @@ -14,7 +14,7 @@ app:layout_behavior="@string/appbar_scrolling_view_behavior"> @@ -47,6 +47,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:background="?android:attr/selectableItemBackground" + android:paddingTop="15dp" android:paddingBottom="15dp"> + android:src="@drawable/ic_update" /> - - - - - - - - - - + android:src="@drawable/ic_backup_restore" /> - - + android:layout_marginEnd="10dp" + android:padding="10dp" + android:src="@drawable/ic_arrow_down_small" /> + android:src="@drawable/ic_logs" /> + + android:src="@drawable/ic_log_level" /> + android:src="@drawable/ic_right_arrow_white" /> + @@ -637,7 +609,7 @@ + android:src="@drawable/ic_translate" /> - + android:src="@drawable/ic_biometric" /> + android:paddingBottom="15dp" + android:visibility="gone"> - diff --git a/app/src/full/res/layout/activity_rpn_countries.xml b/app/src/full/res/layout/activity_rpn_countries.xml new file mode 100644 index 000000000..56dccd491 --- /dev/null +++ b/app/src/full/res/layout/activity_rpn_countries.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + diff --git a/app/src/full/res/layout/activity_tunnel_settings.xml b/app/src/full/res/layout/activity_tunnel_settings.xml index 1de24eba7..388f5665c 100644 --- a/app/src/full/res/layout/activity_tunnel_settings.xml +++ b/app/src/full/res/layout/activity_tunnel_settings.xml @@ -137,9 +137,10 @@ android:layout_marginStart="5dp" android:layout_marginTop="5dp" android:layout_marginEnd="5dp" + android:alpha="0.5" android:layout_marginBottom="5dp" android:padding="10dp" - android:src="@drawable/ic_firewall_exclude_off" /> + android:src="@drawable/ic_loop_back_app" /> + + + + + + + + + + + + + + + + android:src="@drawable/ic_loopback" /> + + + + + + + + + + + + + + + + + + diff --git a/app/src/full/res/layout/activity_welcome.xml b/app/src/full/res/layout/activity_welcome.xml index 20f686c3d..98ebdc65e 100644 --- a/app/src/full/res/layout/activity_welcome.xml +++ b/app/src/full/res/layout/activity_welcome.xml @@ -1,9 +1,10 @@ + android:background="@drawable/welcome_gradient_bg"> + android:background="@android:color/white" /> + android:textColor="@android:color/white" /> + android:textColor="@android:color/white" /> - diff --git a/app/src/full/res/layout/bottom_sheet_app_connections.xml b/app/src/full/res/layout/bottom_sheet_app_connections.xml index 75c471d7c..923ce6e17 100644 --- a/app/src/full/res/layout/bottom_sheet_app_connections.xml +++ b/app/src/full/res/layout/bottom_sheet_app_connections.xml @@ -104,6 +104,77 @@ + + + + + + + + + + + + + + + + + + + - - + android:paddingTop="5dp" + android:paddingEnd="20dp" + android:paddingBottom="5dp"> - + + + android:layout_margin="2dp" + android:layout_marginTop="8dp" + android:gravity="center" + android:paddingStart="10dp" + android:paddingEnd="10dp" + android:textColor="?attr/primaryTextColor" + android:textSize="@dimen/heading_font_text_view" /> + - + - - - + + + - - - - + + + + - - - - - - + android:src="@drawable/ic_info_white" /> - + + + + + + - - - - - - - - - - + android:orientation="vertical" + android:nestedScrollingEnabled="true" + app:fastScrollAutoHide="true" + app:fastScrollAutoHideDelay="1500" + app:fastScrollEnableThumbInactiveColor="true" + app:fastScrollThumbColor="?attr/chipColorBgNormal" + app:fastScrollThumbEnabled="true" + app:fastScrollThumbInactiveColor="?attr/chipColorBgNormal" + app:fastScrollTrackColor="?attr/background" /> - + android:layout_height="wrap_content"> + android:layout_marginLeft="15dp" + android:layout_marginRight="15dp" + android:baselineAligned="true" + android:orientation="horizontal" + android:weightSum="1"> - + - + android:layout_height="match_parent" + android:clipToPadding="false" + app:cardCornerRadius="16dp" + app:cardElevation="0dp" + app:cardUseCompatPadding="true"> - + android:layout_height="140dp" + android:background="@drawable/home_screen_cards_bg" + android:orientation="vertical" + android:paddingTop="10dp"> - + - + - + + - + - - - + - + + - + + + app:cardCornerRadius="16dp" + app:cardElevation="0dp" + app:cardUseCompatPadding="true"> - + android:layout_height="140dp" + android:background="@drawable/home_screen_cards_bg" + android:orientation="vertical" + android:paddingTop="10dp"> - + - + - + - + + - - - + + + - - + + android:orientation="horizontal" + android:weightSum="1"> - + android:layout_weight="0.5" + android:baselineAligned="true" + android:orientation="vertical"> - + app:cardCornerRadius="16dp" + app:cardElevation="0dp" + app:cardUseCompatPadding="true"> - + android:layout_height="140dp" + android:background="@drawable/home_screen_cards_bg" + android:orientation="vertical" + android:paddingTop="10dp"> - + - + + - - - - + + + - + - + app:cardCornerRadius="16dp" + app:cardElevation="0dp" + app:cardUseCompatPadding="true"> - + android:layout_height="140dp" + android:background="@drawable/home_screen_cards_bg" + android:orientation="vertical" + android:paddingTop="10dp"> - + - + - + - - + + + + + - + + + + - @@ -438,7 +464,7 @@ @@ -481,7 +507,7 @@ android:gravity="center" android:paddingStart="10dp" android:paddingEnd="0dp" - android:textColor="?attr/chipTextColor" + android:textColor="?attr/primaryTextColor" android:textSize="@dimen/rethink_header_text" /> - + @@ -813,6 +838,7 @@ android:drawableEnd="@drawable/ic_arrow_down" android:paddingStart="20dp" android:paddingEnd="20dp" + android:letterSpacing="0.2" android:text="@string/hsf_start_btn_state" />