diff --git a/app/build.gradle b/app/build.gradle
index da28756a01..d78eea9129 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -15,10 +15,11 @@ plugins {
}
// apply Google Services and Firebase Crashlytics plugins conditionally
-// strategy: For command-line builds, check task names. For IDE, always apply plugins
-// but they'll only process play/website variants (Firebase deps are scoped to those variants)
+// firebase (google-services + crashlytics) is scoped to the play flavour only.
+// website and fdroid builds are de-googled w.r.t. Firebase (the Google Play Developer
+// API model classes remain available to website as plain dependencies).
def taskNames = gradle.startParameter.taskNames.join(',').toLowerCase()
-def apkBuild = taskNames.contains("full")
+def playBuild = taskNames.contains("play")
def fdroidBuild = taskNames.contains("fdroid")
// for alpha builds generate universal apk only
def alphaBuild = taskNames.contains("alpha")
@@ -26,7 +27,8 @@ def alphaBuild = taskNames.contains("alpha")
// check for fdroidserver value is set in system env
def fdroidBuildServer = System.getenv("fdroidserver")
def isFdroidBuildServer = fdroidBuildServer != null && !fdroidBuildServer.isEmpty() && fdroidBuildServer != "null"
-def deGoogled = !apkBuild || fdroidBuild || isFdroidBuildServer || alphaBuild
+// firebase tooling applies only to play builds; everything else is de-googled
+def deGoogled = !playBuild || fdroidBuild || isFdroidBuildServer || alphaBuild
def shouldSplit = !alphaBuild
// Pass -PwebsiteDegoogled=true when building the fdroid flavor with our own keys.
@@ -34,7 +36,7 @@ def shouldSplit = !alphaBuild
def isWebsiteDegoogled = project.hasProperty("websiteDegoogled") &&
project.property("websiteDegoogled").toString().toBoolean()
-// add google-services.json only for play/website builds.
+// add google-services.json only for play builds.
// local dev : copy from the sibling ../firebase/{debug,release} dir (gitignored, outside repo).
// CI : secret is written to app/src/google-services.json
// by the GitHub Actions; so no local copy is needed there.
@@ -75,20 +77,20 @@ if (!deGoogled) {
}
}
} else {
- logger.info("skipping google-services.json for de-googled/F-Droid build")
+ logger.info("skipping google-services.json for de-googled build (website/fdroid have no Firebase)")
}
println("app-task names: '$taskNames'")
-println("gradle deGoogled? $deGoogled (fdroidBuild: $fdroidBuild, fdroidBuildServer: $isFdroidBuildServer, apkBuild: $apkBuild)")
+println("gradle deGoogled? $deGoogled (playBuild: $playBuild, fdroidBuild: $fdroidBuild, fdroidBuildServer: $isFdroidBuildServer)")
println("gradle alphaBuild? $alphaBuild, should split? $shouldSplit")
-// don't apply firebase plugins for fdroid CLI builds
+// don't apply firebase plugins for non-play builds (website/fdroid are de-googled)
if (!deGoogled) {
apply plugin: 'com.google.gms.google-services'
apply plugin: 'com.google.firebase.crashlytics'
- println("app firebase plugins applied")
+ println("app firebase plugins applied (play build)")
} else {
- println("app firebase plugins SKIPPED")
+ println("app firebase plugins SKIPPED (non-play build)")
}
def keystorePropertiesFile = rootProject.file("keystore.properties")
@@ -228,11 +230,15 @@ android {
buildTypes {
release {
- // modified as part of #352, now webview is removed from app, flipping back
- // the setting to true
- minifyEnabled true
- shrinkResources true
- proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ // AGP 9.3 DSL: enables both code and resource optimization and
+ // includes the default Android keep rules (equivalent to
+ // proguard-android-optimize.txt, which the webview removal in #352
+ // allowed flipping back on). Project keep rules live in
+ // src/main/keepRules/*.keep (see developer.android.com/topic/
+ // performance/app-optimization/enable-app-optimization).
+ optimization {
+ enable = true // Enables code and resource optimizations.
+ }
ndk {
// Use SYMBOL_TABLE to reduce symbol file size significantly
debugSymbolLevel 'SYMBOL_TABLE'
@@ -261,19 +267,20 @@ android {
alpha {
// archive.is/y8uCB
applicationIdSuffix ".alpha"
- minifyEnabled true
- shrinkResources true
+ optimization {
+ enable = true // Enables code and resource optimizations (AGP 9.3 DSL).
+ }
signingConfig signingConfigs.alpha
resValue "string", "app_name", "Rethink(α)"
- proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
releaseDebug {
// for testing release build with debug signing config
initWith buildTypes.release
- minifyEnabled true
- shrinkResources true
+ // set explicitly; initWith may not copy the optimization block
+ optimization {
+ enable = true // Enables code and resource optimizations (AGP 9.3 DSL).
+ }
signingConfig signingConfigs.debug
- proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
ndk {
// Use SYMBOL_TABLE to reduce symbol file size significantly
debugSymbolLevel 'SYMBOL_TABLE'
@@ -291,7 +298,7 @@ android {
}
// fix: injectCrashlyticsBuildIds task has a buffer overflow bug
- // apply crashlytics configuration to firebase builds only
+ // apply crashlytics configuration to play (firebase) builds only
if (!deGoogled) {
// workaround for crashlytics gradle plugin bug with large native symbols
afterEvaluate {
@@ -339,12 +346,6 @@ android {
targetCompatibility JavaVersion.VERSION_17
}
- packagingOptions {
- jniLibs {
- keepDebugSymbols += ['**/*.so']
- }
- }
-
// required for google play developer api model classes
packaging {
resources.excludes += "META-INF/INDEX.LIST"
@@ -740,6 +741,8 @@ dependencies {
// The version v3-rev20240301-2.0.0 cited in the API docs does not exist on Maven Central;
// v3-rev20260318-2.0.0 is the closest available release with the same model classes.
// ref: github.com/googleapis/google-api-java-client-services/tree/main/clients/google-api-services-androidpublisher/v3
+ // scoped to play + website only (server order-history / billing backend).
+ // Not included in fdroid (and hence Izzy) builds;
playImplementation('com.google.apis:google-api-services-androidpublisher:v3-rev20260318-2.0.0') {
// Exclude Apache HTTP transport, conflicts with Android's built-in HTTP stack
exclude group: 'com.google.http-client', module: 'google-http-client-apache-v2'
@@ -753,18 +756,13 @@ dependencies {
exclude group: 'com.google.oauth-client'
}
-
lintChecks 'com.android.security.lint:lint:1.0.4'
// battery optimization permission helper
implementation 'com.waseemsabir:betterypermissionhelper:1.0.3'
- // Firebase dependencies for error reporting (website and play variants only)
- websiteImplementation platform('com.google.firebase:firebase-bom:34.17.0')
- websiteImplementation 'com.google.firebase:firebase-crashlytics'
- websiteImplementation 'com.google.firebase:firebase-crashlytics-ndk'
-
- playImplementation platform('com.google.firebase:firebase-bom:34.17.0')
+ // Firebase dependencies for error reporting (play flavour only)
+ playImplementation platform('com.google.firebase:firebase-bom:34.18.0')
playImplementation 'com.google.firebase:firebase-crashlytics'
playImplementation 'com.google.firebase:firebase-crashlytics-ndk'
}
diff --git a/app/src/fdroid/java/com/celzero/bravedns/iab/InAppBillingHandler.kt b/app/src/fdroid/java/com/celzero/bravedns/iab/InAppBillingHandler.kt
index bae1cccaca..4b1a15a01d 100644
--- a/app/src/fdroid/java/com/celzero/bravedns/iab/InAppBillingHandler.kt
+++ b/app/src/fdroid/java/com/celzero/bravedns/iab/InAppBillingHandler.kt
@@ -43,7 +43,9 @@ object InAppBillingHandler {
const val REVOKE_WINDOW_SUBS_MONTHLY_DAYS = 3
const val REVOKE_WINDOW_SUBS_YEARLY_DAYS = 7
const val REVOKE_WINDOW_ONE_TIME_2YRS_DAYS = 14 // 2 * 7
- const val REVOKE_WINDOW_ONE_TIME_5YRS_DAYS = 35 // 5 * 7
+ const val REVOKE_WINDOW_ONE_TIME_5YRS_DAYS = 28 // 4 * 7
+
+ const val MONEYBACK_WINDOW_DAYS = 31
const val PLAY_SUBS_LINK = "https://play.google.com/store/account/subscriptions?sku=\$1&package=\$2"
const val HISTORY_LINK = ""
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
index 3dc7174d38..21d8888a30 100644
--- a/app/src/fdroid/java/com/celzero/bravedns/iab/stripe/RetrofitInstance.kt
+++ b/app/src/fdroid/java/com/celzero/bravedns/iab/stripe/RetrofitInstance.kt
@@ -2,13 +2,17 @@ package com.celzero.bravedns.iab.stripe
import com.celzero.bravedns.customdownloader.RetrofitManager
import okhttp3.OkHttpClient
+import java.net.Proxy
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()
+ // NO_PROXY: never consult ProxySelector.getDefault(), which throws
+ // IllegalArgumentException("port out of range:-1") when the device has a global
+ // HTTP proxy configured without a port (http(s).proxyPort system property is -1).
+ private val client = OkHttpClient.Builder().proxy(Proxy.NO_PROXY).build()
/*val api: StripeApiService by lazy {
RetrofitManager.getStripeBaseBuilder(0)
diff --git a/app/src/full/AndroidManifest.xml b/app/src/full/AndroidManifest.xml
index 49ba11ee99..ac4422b74b 100644
--- a/app/src/full/AndroidManifest.xml
+++ b/app/src/full/AndroidManifest.xml
@@ -127,12 +127,21 @@
+
+
+
diff --git a/app/src/full/res/color/chip_toggle_stroke.xml b/app/src/full/res/color/chip_toggle_stroke.xml
new file mode 100644
index 0000000000..5294418847
--- /dev/null
+++ b/app/src/full/res/color/chip_toggle_stroke.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
diff --git a/app/src/full/res/color/chip_toggle_text.xml b/app/src/full/res/color/chip_toggle_text.xml
new file mode 100644
index 0000000000..9cfbfa0936
--- /dev/null
+++ b/app/src/full/res/color/chip_toggle_text.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
diff --git a/app/src/full/res/color/time_segment_fill.xml b/app/src/full/res/color/time_segment_fill.xml
new file mode 100644
index 0000000000..112094239b
--- /dev/null
+++ b/app/src/full/res/color/time_segment_fill.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/full/res/color/time_segment_stroke.xml b/app/src/full/res/color/time_segment_stroke.xml
new file mode 100644
index 0000000000..8743b9d227
--- /dev/null
+++ b/app/src/full/res/color/time_segment_stroke.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/full/res/color/time_segment_text_color.xml b/app/src/full/res/color/time_segment_text_color.xml
new file mode 100644
index 0000000000..176d8237e8
--- /dev/null
+++ b/app/src/full/res/color/time_segment_text_color.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/full/res/drawable/bg_activity_avatar.xml b/app/src/full/res/drawable/bg_activity_avatar.xml
new file mode 100644
index 0000000000..dfcc6f7b63
--- /dev/null
+++ b/app/src/full/res/drawable/bg_activity_avatar.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/app/src/full/res/drawable/bg_status_dot.xml b/app/src/full/res/drawable/bg_status_dot.xml
new file mode 100644
index 0000000000..4b3cc18230
--- /dev/null
+++ b/app/src/full/res/drawable/bg_status_dot.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/app/src/full/res/layout-sw600dp/fragment_home_screen.xml b/app/src/full/res/layout-sw600dp/fragment_home_screen.xml
deleted file mode 100644
index be682d0770..0000000000
--- a/app/src/full/res/layout-sw600dp/fragment_home_screen.xml
+++ /dev/null
@@ -1,857 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/full/res/layout/activity_advanced_setting.xml b/app/src/full/res/layout/activity_advanced_setting.xml
index c31553cb00..f0c03157e6 100644
--- a/app/src/full/res/layout/activity_advanced_setting.xml
+++ b/app/src/full/res/layout/activity_advanced_setting.xml
@@ -324,6 +324,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/full/res/layout/activity_console_log.xml b/app/src/full/res/layout/activity_console_log.xml
index 06321caaf2..129ce2a829 100644
--- a/app/src/full/res/layout/activity_console_log.xml
+++ b/app/src/full/res/layout/activity_console_log.xml
@@ -106,7 +106,7 @@
android:layout_marginBottom="16dp"
android:textColor="?attr/homeScreenHeaderTextColor"
app:backgroundTint="?attr/buttonBackground"
- android:text="@string/about_bug_report_desc" />
+ android:text="@string/about_email" />
-
+ android:layout_marginEnd="20dp">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/full/res/layout/dialog_add_custom_domain.xml b/app/src/full/res/layout/dialog_add_custom_domain.xml
index 3a6e960bec..96f68dcc19 100644
--- a/app/src/full/res/layout/dialog_add_custom_domain.xml
+++ b/app/src/full/res/layout/dialog_add_custom_domain.xml
@@ -2,7 +2,7 @@
@@ -126,42 +126,49 @@
android:visibility="gone"
app:layout_constraintTop_toBottomOf="@id/dacd_input_layout" />
-
+ android:layout_marginTop="10dp"
+ android:baselineAligned="false"
+ android:clipToPadding="false"
+ android:gravity="end"
+ android:measureWithLargestChild="true"
+ android:orientation="horizontal"
+ app:layout_constraintTop_toBottomOf="@id/dacd_failure_text">
-
+
-
+
+
+
+
+
diff --git a/app/src/full/res/layout/dialog_set_custom_doh.xml b/app/src/full/res/layout/dialog_set_custom_doh.xml
index 6dc6dd4b8a..4218d6c1d1 100644
--- a/app/src/full/res/layout/dialog_set_custom_doh.xml
+++ b/app/src/full/res/layout/dialog_set_custom_doh.xml
@@ -15,72 +15,103 @@
android:textColor="?attr/primaryTextColor"
app:layout_constraintTop_toTopOf="parent" />
-
-
-
-
-
+ android:orientation="vertical">
-
-
-
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent" />
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toStartOf="@id/dialog_custom_url_ok_btn" />
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent" />
diff --git a/app/src/full/res/layout/dialog_set_custom_odoh.xml b/app/src/full/res/layout/dialog_set_custom_odoh.xml
index 1712ec9837..6afa4153d8 100644
--- a/app/src/full/res/layout/dialog_set_custom_odoh.xml
+++ b/app/src/full/res/layout/dialog_set_custom_odoh.xml
@@ -14,78 +14,91 @@
android:textColor="?attr/primaryTextColor"
app:layout_constraintTop_toTopOf="parent" />
-
-
-
+ android:orientation="vertical">
-
+
-
-
+
+
-
+
-
-
+
+
-
+
+
+
+
+
+
+
+
+
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent" />
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toStartOf="@id/dialog_custom_url_ok_btn" />
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent" />
diff --git a/app/src/full/res/layout/dialog_set_dns_crypt.xml b/app/src/full/res/layout/dialog_set_dns_crypt.xml
index 11eb90dcd4..01f6a00394 100644
--- a/app/src/full/res/layout/dialog_set_dns_crypt.xml
+++ b/app/src/full/res/layout/dialog_set_dns_crypt.xml
@@ -9,6 +9,9 @@
android:id="@+id/dialog_dns_crypt_scroll"
android:layout_width="match_parent"
android:layout_height="wrap_content"
+ android:fillViewport="true"
+ app:layout_constrainedHeight="true"
+ app:layout_constraintBottom_toTopOf="@id/dialog_dns_crypt_ok_btn"
app:layout_constraintTop_toTopOf="parent">
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent" />
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toStartOf="@id/dialog_dns_crypt_ok_btn" />
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent" />
diff --git a/app/src/full/res/layout/dialog_set_dns_proxy.xml b/app/src/full/res/layout/dialog_set_dns_proxy.xml
index f134835c72..b459d285a2 100644
--- a/app/src/full/res/layout/dialog_set_dns_proxy.xml
+++ b/app/src/full/res/layout/dialog_set_dns_proxy.xml
@@ -1,209 +1,224 @@
-
+ android:padding="10dp">
+ android:textColor="?attr/primaryTextColor"
+ app:layout_constraintTop_toTopOf="parent" />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ android:fillViewport="true"
+ app:layout_constrainedHeight="true"
+ app:layout_constraintBottom_toTopOf="@id/dialog_dns_proxy_cancel_btn"
+ app:layout_constraintTop_toBottomOf="@id/dialog_dns_proxy_heading">
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ android:layout_margin="5dp"
+ android:gravity="center"
+ android:text="@string/lbl_add"
+ android:textColor="?attr/accentGood"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent" />
+
+
diff --git a/app/src/full/res/layout/fragment_custom_domain.xml b/app/src/full/res/layout/fragment_custom_domain.xml
index baa0ef4704..3674af6c81 100644
--- a/app/src/full/res/layout/fragment_custom_domain.xml
+++ b/app/src/full/res/layout/fragment_custom_domain.xml
@@ -98,7 +98,9 @@
android:id="@+id/cda_recycler"
android:layout_width="match_parent"
android:layout_height="match_parent"
- android:layout_below="@id/cda_show_rules_rl" />
+ android:layout_below="@id/cda_show_rules_rl"
+ android:paddingBottom="96dp"
+ android:clipToPadding="false" />
diff --git a/app/src/full/res/layout/fragment_custom_ip.xml b/app/src/full/res/layout/fragment_custom_ip.xml
index b992b478fe..7a290e68e8 100644
--- a/app/src/full/res/layout/fragment_custom_ip.xml
+++ b/app/src/full/res/layout/fragment_custom_ip.xml
@@ -101,6 +101,8 @@
android:layout_marginTop="2dp"
android:layout_marginEnd="2dp"
android:layout_marginBottom="2dp"
+ android:paddingBottom="96dp"
+ android:clipToPadding="false"
android:nestedScrollingEnabled="true" />
diff --git a/app/src/full/res/layout/fragment_home_screen.xml b/app/src/full/res/layout/fragment_home_screen.xml
index 1a436ce845..144930e4af 100644
--- a/app/src/full/res/layout/fragment_home_screen.xml
+++ b/app/src/full/res/layout/fragment_home_screen.xml
@@ -1,865 +1,1039 @@
-
+ android:layout_marginBottom="80dp">
-
+ android:layout_height="0dp"
+ android:fillViewport="true"
+ app:layout_constraintBottom_toTopOf="@id/fhs_control_cluster"
+ app:layout_constraintTop_toTopOf="parent">
-
-
-
+ android:maxWidth="720dp"
+ android:layout_gravity="center_horizontal"
+ android:paddingBottom="32dp">
-
-
-
+
+
+
+
+
+
+
+
+ app:cardBackgroundColor="?attr/colorSurfaceContainer"
+ app:cardCornerRadius="24dp"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@id/fhs_protection_bar"
+ app:layout_constraintWidth_percent="0.65">
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+ android:layout_height="120dp"
+ android:layout_marginStart="2dp"
+ app:layout_constraintTop_toBottomOf="@id/fhs_card_dns_ll">
+ android:layout_height="wrap_content"
+ android:gravity="center"
+ android:orientation="vertical"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toStartOf="@+id/fhs_card_proxy_ll"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintWidth_percent="0.50">
+
+
-
+
-
+
-
+
-
+
+
+
-
+
-
+
-
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
-
+
-
-
-
+
+
+ android:padding="12dp">
-
+ android:ellipsize="end"
+ android:fontFamily="sans-serif-medium"
+ android:maxLines="1"
+ tools:text="WireGuard"
+ android:textColor="?attr/primaryTextColor"
+ android:textSize="@dimen/large_font_text_view" />
+
+
-
+
-
+
+
+
+
+
+
+
+
+
-
+ android:layout_marginTop="3dp"
+ android:orientation="horizontal">
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
-
+
-
+ app:cardBackgroundColor="?attr/colorSurfaceContainer"
+ app:cardCornerRadius="18dp"
+ android:layout_marginStart="2dp"
+ android:layout_marginEnd="2dp"
+ app:layout_constraintTop_toBottomOf="@id/fhs_firewall_proxy_row">
-
+
-
+
-
-
-
+ android:orientation="vertical">
+ android:fontFamily="monospace"
+ tools:text="62K"
+ android:textColor="?attr/primaryTextColor"
+ android:textSize="@dimen/rethink_sub_header_text" />
-
-
-
-
-
-
-
-
-
+ android:alpha="0.8"
+ android:letterSpacing="0.15"
+ android:text="@string/lbl_allowed"
+ android:textAllCaps="true"
+ android:textColor="?attr/primaryLightColorText"
+ android:textSize="@dimen/small_font_text_view" />
+
-
+ android:layout_marginStart="24dp"
+ android:orientation="vertical">
+ android:fontFamily="monospace"
+ tools:text="65K"
+ android:textColor="?attr/primaryLightColorText"
+ android:textSize="@dimen/rethink_header_text" />
+ android:alpha="0.8"
+ android:letterSpacing="0.15"
+ android:text="@string/lbl_blocked"
+ android:textAllCaps="true"
+ android:textColor="?attr/primaryLightColorText"
+ android:textSize="@dimen/small_font_text_view" />
+
-
+
-
-
-
-
-
-
+
-
+
+
+
+
+
+
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:layout_height="wrap_content"
+ app:cardBackgroundColor="?attr/colorSurfaceContainer"
+ app:cardCornerRadius="18dp"
+ android:layout_marginStart="2dp"
+ android:layout_marginEnd="2dp"
+ app:layout_constraintTop_toBottomOf="@id/fhs_card_logs_ll">
-
-
+ android:layout_height="match_parent"
+ android:paddingHorizontal="16dp"
+ android:paddingVertical="12dp">
-
+ app:layout_constraintTop_toTopOf="parent">
+ android:fontFamily="monospace"
+ tools:text="459"
+ android:textColor="?attr/primaryTextColor"
+ android:textSize="@dimen/home_screen_stat_text" />
+ android:fontFamily="monospace"
+ tools:text="/461"
+ android:textColor="?attr/primaryLightColorText"
+ android:textSize="@dimen/large_font_text_view" />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ android:text="@string/apps_info_title"
+ android:textAllCaps="true"
+ android:textColor="?attr/primaryLightColorText"
+ android:textSize="@dimen/default_font_text_view" />
+
+
+
-
-
+
-
+
-
+
+ android:alpha="0.8"
+ android:letterSpacing="0.02"
+ tools:text="0 excluded"
+ android:textAllCaps="true"
+ android:textColor="?attr/primaryLightColorText"
+ android:textSize="@dimen/small_font_text_view"
+ tools:ignore="MissingConstraints" />
+ app:layout_constraintTop_toTopOf="parent" />
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
+
-
-
+
-
-
-
-
-
-
-
+
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
diff --git a/app/src/full/res/layout/fragment_proxy_configure.xml b/app/src/full/res/layout/fragment_proxy_configure.xml
index 637a02d901..f48cb8a604 100644
--- a/app/src/full/res/layout/fragment_proxy_configure.xml
+++ b/app/src/full/res/layout/fragment_proxy_configure.xml
@@ -123,6 +123,7 @@
android:layout_gravity="center_vertical"
android:paddingTop="5dp"
android:paddingBottom="5dp"
+ android:ellipsize="marquee"
android:text="@string/proxy_rpn_desc_inactive"
android:textSize="@dimen/default_font_text_view" />
diff --git a/app/src/full/res/layout/fragment_sponsor.xml b/app/src/full/res/layout/fragment_sponsor.xml
index c50c30a9e1..6dc3a61c19 100644
--- a/app/src/full/res/layout/fragment_sponsor.xml
+++ b/app/src/full/res/layout/fragment_sponsor.xml
@@ -20,7 +20,6 @@
android:paddingTop="20dp"
android:paddingBottom="8dp">
-
-
-
-
-
-
-
+ style="@style/Widget.Material3.Button.OutlinedButton" />
diff --git a/app/src/full/res/layout/item_log_activity_app.xml b/app/src/full/res/layout/item_log_activity_app.xml
new file mode 100644
index 0000000000..79a2dea9ea
--- /dev/null
+++ b/app/src/full/res/layout/item_log_activity_app.xml
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/full/res/layout/item_log_activity_conn.xml b/app/src/full/res/layout/item_log_activity_conn.xml
new file mode 100644
index 0000000000..692007405e
--- /dev/null
+++ b/app/src/full/res/layout/item_log_activity_conn.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/full/res/layout/list_item_firewall_app.xml b/app/src/full/res/layout/list_item_firewall_app.xml
index f07b3b3ea8..d8597764e7 100644
--- a/app/src/full/res/layout/list_item_firewall_app.xml
+++ b/app/src/full/res/layout/list_item_firewall_app.xml
@@ -83,6 +83,7 @@
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
+ android:visibility="gone"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
tools:text="com.android.chrome (10123)" />
diff --git a/app/src/full/res/layout/rethink_endpoint_list_item.xml b/app/src/full/res/layout/rethink_endpoint_list_item.xml
index 280cba2b11..767c82b45b 100644
--- a/app/src/full/res/layout/rethink_endpoint_list_item.xml
+++ b/app/src/full/res/layout/rethink_endpoint_list_item.xml
@@ -66,28 +66,45 @@
-
+ android:layout_centerVertical="true"
+ android:layout_marginEnd="12dp">
+
+
+
+
+
+
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 9c8af92a1a..56d60b9cbf 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,8 +1,8 @@
+ android:versionCode="68"
+ android:versionName="v057">
diff --git a/app/src/main/assets/database/rethink_v22.db b/app/src/main/assets/database/rethink_v22.db
deleted file mode 100644
index 40902c28ed..0000000000
Binary files a/app/src/main/assets/database/rethink_v22.db and /dev/null differ
diff --git a/app/src/main/java/com/celzero/bravedns/RethinkDnsApplication.kt b/app/src/main/java/com/celzero/bravedns/RethinkDnsApplication.kt
index 93769b8481..6cd5717822 100644
--- a/app/src/main/java/com/celzero/bravedns/RethinkDnsApplication.kt
+++ b/app/src/main/java/com/celzero/bravedns/RethinkDnsApplication.kt
@@ -23,7 +23,6 @@ import android.os.StrictMode
import com.celzero.bravedns.scheduler.EnhancedBugReport
import com.celzero.bravedns.scheduler.ScheduleManager
import com.celzero.bravedns.scheduler.WorkScheduler
-import com.celzero.bravedns.util.FirebaseErrorReporting
import com.celzero.bravedns.util.GlobalExceptionHandler
import com.celzero.bravedns.util.GoReportingHandler
import kotlinx.coroutines.CoroutineScope
@@ -56,15 +55,10 @@ class RethinkDnsApplication : Application() {
// Initialize global exception handler
GlobalExceptionHandler.initialize(this)
- FirebaseErrorReporting.initialize()
+ // firebase error reporting is play-flavor only; initialized in
+ // RethinkDnsApplicationPlay. website/fdroid variants use stubs.
GoReportingHandler.initialize(appScope, this)
- // On every app start, report any tombstone files from the previous session
- val appCtx = this
- appScope.launch(Dispatchers.IO) {
- EnhancedBugReport.reportTombstonesToFirebaseOnStartup(appCtx)
- }
-
turnOnStrictMode()
appScope.launch {
diff --git a/app/src/main/java/com/celzero/bravedns/ServiceModuleProvider.kt b/app/src/main/java/com/celzero/bravedns/ServiceModuleProvider.kt
index a430b06006..b44093a2b4 100644
--- a/app/src/main/java/com/celzero/bravedns/ServiceModuleProvider.kt
+++ b/app/src/main/java/com/celzero/bravedns/ServiceModuleProvider.kt
@@ -32,11 +32,18 @@ import com.celzero.bravedns.service.ServiceModule
import com.celzero.bravedns.util.Constants
import com.celzero.bravedns.util.OrbotHelper
import com.celzero.bravedns.viewmodel.ViewModelModule
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
import org.koin.android.ext.koin.androidContext
import org.koin.core.module.Module
import org.koin.dsl.module
-private val rootModule = module { single { androidContext().contentResolver } }
+private val rootModule =
+ module {
+ single { androidContext().contentResolver }
+ single { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
+ }
private val updaterModule = module {
single { NonStoreAppUpdater(Constants.RETHINK_APP_UPDATE_CHECK, get()) }
single { get() }
diff --git a/app/src/main/java/com/celzero/bravedns/adapter/AppWiseDomainsAdapter.kt b/app/src/main/java/com/celzero/bravedns/adapter/AppWiseDomainsAdapter.kt
index c40b89e439..c3eee9e980 100644
--- a/app/src/main/java/com/celzero/bravedns/adapter/AppWiseDomainsAdapter.kt
+++ b/app/src/main/java/com/celzero/bravedns/adapter/AppWiseDomainsAdapter.kt
@@ -389,7 +389,7 @@ class AppWiseDomainsAdapter(
when (status) {
DomainRulesManager.Status.NONE -> {
b.progress.setIndicatorColor(
- UIUtils.fetchToggleBtnColors(context, R.color.chipTextNeutral)
+ UIUtils.fetchToggleBtnColors(context, R.color.accentGood)
)
}
DomainRulesManager.Status.BLOCK -> {
diff --git a/app/src/main/java/com/celzero/bravedns/adapter/AppWiseIpsAdapter.kt b/app/src/main/java/com/celzero/bravedns/adapter/AppWiseIpsAdapter.kt
index d573fa6667..59cf657a9f 100644
--- a/app/src/main/java/com/celzero/bravedns/adapter/AppWiseIpsAdapter.kt
+++ b/app/src/main/java/com/celzero/bravedns/adapter/AppWiseIpsAdapter.kt
@@ -178,7 +178,7 @@ class AppWiseIpsAdapter(val context: Context, val lifecycleOwner: LifecycleOwner
when (status) {
IpRulesManager.IpRuleStatus.NONE -> {
b.progress.setIndicatorColor(
- UIUtils.fetchToggleBtnColors(context, R.color.chipTextNeutral)
+ UIUtils.fetchToggleBtnColors(context, R.color.accentGood)
)
}
IpRulesManager.IpRuleStatus.BLOCK -> {
diff --git a/app/src/main/java/com/celzero/bravedns/adapter/CustomDomainAdapter.kt b/app/src/main/java/com/celzero/bravedns/adapter/CustomDomainAdapter.kt
index 420e0ad0c1..91d8781815 100644
--- a/app/src/main/java/com/celzero/bravedns/adapter/CustomDomainAdapter.kt
+++ b/app/src/main/java/com/celzero/bravedns/adapter/CustomDomainAdapter.kt
@@ -30,6 +30,7 @@ import android.widget.ImageView
import android.widget.Toast
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
+import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.paging.PagingDataAdapter
@@ -651,6 +652,14 @@ class CustomDomainAdapter(
}
private suspend fun uiCtx(f: suspend () -> Unit) {
- withContext(Dispatchers.Main) { f() }
+ val owner = context as? LifecycleOwner ?: return
+
+ withContext(Dispatchers.Main.immediate) {
+ if (!owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
+ return@withContext
+ }
+
+ f()
+ }
}
}
diff --git a/app/src/main/java/com/celzero/bravedns/adapter/CustomIpAdapter.kt b/app/src/main/java/com/celzero/bravedns/adapter/CustomIpAdapter.kt
index 521c4a0efc..0af4ff8cc3 100644
--- a/app/src/main/java/com/celzero/bravedns/adapter/CustomIpAdapter.kt
+++ b/app/src/main/java/com/celzero/bravedns/adapter/CustomIpAdapter.kt
@@ -29,6 +29,7 @@ import android.view.WindowManager
import android.widget.ImageView
import androidx.core.view.isVisible
import androidx.core.widget.addTextChangedListener
+import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.paging.PagingDataAdapter
@@ -631,6 +632,15 @@ class CustomIpAdapter(private val context: Context, private val type: CustomRule
dBind.daciFailureTextView.visibility = View.VISIBLE
return@ui
}
+
+ // reject non-CIDR-able input such as "1.1.1.1-55"; the ip trie only
+ // accepts CIDR notation and would reject the rule (see isCidrEnforceable)
+ if (!IpRulesManager.isCidrEnforceable(ip)) {
+ dBind.daciFailureTextView.text =
+ context.getString(R.string.ci_dialog_error_invalid_cidr)
+ dBind.daciFailureTextView.visibility = View.VISIBLE
+ return@ui
+ }
Logger.i(LOG_TAG_UI, "$TAG ip: $ip, port: $port, status: $status")
updateCustomIp(customIp, ip, port, status)
}
@@ -657,7 +667,15 @@ class CustomIpAdapter(private val context: Context, private val type: CustomRule
}
private suspend fun uiCtx(f: suspend () -> Unit) {
- withContext(Dispatchers.Main) { f() }
+ val owner = context as? LifecycleOwner ?: return
+
+ withContext(Dispatchers.Main.immediate) {
+ if (!owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
+ return@withContext
+ }
+
+ f()
+ }
}
private fun io(f: suspend () -> Unit) {
diff --git a/app/src/main/java/com/celzero/bravedns/adapter/DnsCryptEndpointAdapter.kt b/app/src/main/java/com/celzero/bravedns/adapter/DnsCryptEndpointAdapter.kt
index 389ee4d2cb..df644ec2df 100644
--- a/app/src/main/java/com/celzero/bravedns/adapter/DnsCryptEndpointAdapter.kt
+++ b/app/src/main/java/com/celzero/bravedns/adapter/DnsCryptEndpointAdapter.kt
@@ -24,6 +24,7 @@ import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
+import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.lifecycleScope
@@ -31,6 +32,7 @@ import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.celzero.bravedns.R
+import com.celzero.bravedns.util.SelectionIndicator
import com.celzero.bravedns.customdownloader.IpInfoDownloader
import com.celzero.bravedns.data.AppConfig
import com.celzero.bravedns.database.DnsCryptEndpoint
@@ -96,6 +98,11 @@ class DnsCryptEndpointAdapter(private val context: Context, private val appConfi
inner class DnsCryptEndpointViewHolder(private val b: DnsCryptEndpointListItemBinding) :
RecyclerView.ViewHolder(b.root) {
private var statusCheckJob: Job? = null
+ private val selectionIndicator =
+ SelectionIndicator(
+ b.dnsCryptEndpointListSelectionOrbital,
+ b.dnsCryptEndpointListSelectionPill
+ )
fun update(endpoint: DnsCryptEndpoint) {
displayDetails(endpoint)
@@ -103,13 +110,7 @@ class DnsCryptEndpointAdapter(private val context: Context, private val appConfi
}
private fun setupClickListeners(endpoint: DnsCryptEndpoint) {
- b.root.setOnClickListener {
- b.dnsCryptEndpointListActionImage.isChecked =
- !b.dnsCryptEndpointListActionImage.isChecked
- updateDnsCryptDetails(endpoint)
- }
-
- b.dnsCryptEndpointListActionImage.setOnClickListener { updateDnsCryptDetails(endpoint) }
+ b.root.setOnClickListener { updateDnsCryptDetails(endpoint) }
b.dnsCryptEndpointListInfoImage.setOnClickListener {
showExplanationOnImageClick(endpoint)
@@ -118,7 +119,14 @@ class DnsCryptEndpointAdapter(private val context: Context, private val appConfi
private fun displayDetails(endpoint: DnsCryptEndpoint) {
b.dnsCryptEndpointListUrlName.text = endpoint.dnsCryptName
- b.dnsCryptEndpointListActionImage.isChecked = endpoint.isSelected
+ b.root.contentDescription =
+ context.getString(
+ if (endpoint.isSelected) R.string.dns_list_item_selected_cd
+ else R.string.dns_list_item_select_cd,
+ endpoint.dnsCryptName
+ )
+ selectionIndicator.update(endpoint.isSelected)
+
if (endpoint.isSelected && VpnController.hasTunnel() && !appConfig.isSmartDnsEnabled()) {
keepSelectedStatusUpdated()
@@ -305,7 +313,15 @@ class DnsCryptEndpointAdapter(private val context: Context, private val appConfi
}
private suspend fun uiCtx(f: suspend () -> Unit) {
- withContext(Dispatchers.Main) { f() }
+ val owner = lifecycleOwner ?: return
+
+ withContext(Dispatchers.Main.immediate) {
+ if (!owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
+ return@withContext
+ }
+
+ f()
+ }
}
private fun ui(f: suspend () -> Unit): Job? {
diff --git a/app/src/main/java/com/celzero/bravedns/adapter/DnsCryptRelayEndpointAdapter.kt b/app/src/main/java/com/celzero/bravedns/adapter/DnsCryptRelayEndpointAdapter.kt
index 5f4ba8887e..e7c1c62719 100644
--- a/app/src/main/java/com/celzero/bravedns/adapter/DnsCryptRelayEndpointAdapter.kt
+++ b/app/src/main/java/com/celzero/bravedns/adapter/DnsCryptRelayEndpointAdapter.kt
@@ -23,12 +23,14 @@ import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.content.ContextCompat
+import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.celzero.bravedns.R
+import com.celzero.bravedns.util.SelectionIndicator
import com.celzero.bravedns.customdownloader.IpInfoDownloader
import com.celzero.bravedns.data.AppConfig
import com.celzero.bravedns.database.DnsCryptRelayEndpoint
@@ -95,6 +97,11 @@ class DnsCryptRelayEndpointAdapter(
inner class DnsCryptRelayEndpointViewHolder(private val b: DnsCryptEndpointListItemBinding) :
RecyclerView.ViewHolder(b.root) {
+ private val selectionIndicator =
+ SelectionIndicator(
+ b.dnsCryptEndpointListSelectionOrbital,
+ b.dnsCryptEndpointListSelectionPill
+ )
fun update(endpoint: DnsCryptRelayEndpoint) {
displayDetails(endpoint)
@@ -103,13 +110,7 @@ class DnsCryptRelayEndpointAdapter(
private fun setupClickListener(endpoint: DnsCryptRelayEndpoint) {
b.root.setOnClickListener {
- b.dnsCryptEndpointListActionImage.isChecked =
- !b.dnsCryptEndpointListActionImage.isChecked
- updateDNSCryptRelayDetails(endpoint, b.dnsCryptEndpointListActionImage.isChecked)
- }
-
- b.dnsCryptEndpointListActionImage.setOnClickListener {
- updateDNSCryptRelayDetails(endpoint, b.dnsCryptEndpointListActionImage.isChecked)
+ updateDNSCryptRelayDetails(endpoint, !endpoint.isSelected)
}
b.dnsCryptEndpointListInfoImage.setOnClickListener { promptUser(endpoint) }
@@ -117,6 +118,13 @@ class DnsCryptRelayEndpointAdapter(
private fun displayDetails(endpoint: DnsCryptRelayEndpoint) {
b.dnsCryptEndpointListUrlName.text = endpoint.dnsCryptRelayName
+ b.root.contentDescription =
+ context.getString(
+ if (endpoint.isSelected) R.string.dns_list_item_selected_cd
+ else R.string.dns_list_item_select_cd,
+ endpoint.dnsCryptRelayName
+ )
+ selectionIndicator.update(endpoint.isSelected)
if (endpoint.isSelected && !appConfig.isSmartDnsEnabled()) {
updateSelectedStatus()
} else {
@@ -124,7 +132,6 @@ class DnsCryptRelayEndpointAdapter(
b.dnsCryptEndpointListUrlExplanation.visibility = View.GONE
}
- b.dnsCryptEndpointListActionImage.isChecked = endpoint.isSelected
if (endpoint.isDeletable()) {
b.dnsCryptEndpointListInfoImage.setImageDrawable(
ContextCompat.getDrawable(context, R.drawable.ic_fab_uninstall)
@@ -231,7 +238,6 @@ class DnsCryptRelayEndpointAdapter(
context.getString(R.string.dns_crypt_relay_error_toast),
Toast.LENGTH_LONG
)
- b.dnsCryptEndpointListActionImage.isChecked = false
}
return@io
}
@@ -291,7 +297,15 @@ class DnsCryptRelayEndpointAdapter(
}
private suspend fun uiCtx(f: suspend () -> Unit) {
- withContext(Dispatchers.Main) { f() }
+ val owner = lifecycleOwner
+
+ withContext(Dispatchers.Main.immediate) {
+ if (!owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
+ return@withContext
+ }
+
+ f()
+ }
}
}
}
diff --git a/app/src/main/java/com/celzero/bravedns/adapter/DnsLogAdapter.kt b/app/src/main/java/com/celzero/bravedns/adapter/DnsLogAdapter.kt
index 1696ca9ae3..85b38ad93e 100644
--- a/app/src/main/java/com/celzero/bravedns/adapter/DnsLogAdapter.kt
+++ b/app/src/main/java/com/celzero/bravedns/adapter/DnsLogAdapter.kt
@@ -46,6 +46,8 @@ 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.service.ProxyManager.ID_WG_BASE
+import com.celzero.bravedns.service.WireguardManager
import com.celzero.bravedns.ui.bottomsheet.DnsBlocklistBottomSheet
import com.celzero.bravedns.util.Constants
import com.celzero.bravedns.util.Constants.Companion.MAX_ENDPOINT
@@ -405,6 +407,13 @@ class DnsLogAdapter(val context: Context, val loadFavIcon: Boolean, val isRethin
}
private fun displayDnsType(log: DnsLog) {
+ if (ProxyManager.isRpnProxy(log.proxyId)) {
+ b.dnsTypeName.text = context.getString(R.string.rpn_title)
+ return
+ } else if (isConnectionProxied(log.proxyId) && log.proxyId.startsWith(ID_WG_BASE)) {
+ b.dnsTypeName.text = context.getString(R.string.lbl_wg)
+ return
+ }
val type = Transaction.TransportType.fromOrdinal(log.dnsType)
when (type) {
Transaction.TransportType.DOH -> {
diff --git a/app/src/main/java/com/celzero/bravedns/adapter/DnsProxyEndpointAdapter.kt b/app/src/main/java/com/celzero/bravedns/adapter/DnsProxyEndpointAdapter.kt
index f22974417e..711146f4a3 100644
--- a/app/src/main/java/com/celzero/bravedns/adapter/DnsProxyEndpointAdapter.kt
+++ b/app/src/main/java/com/celzero/bravedns/adapter/DnsProxyEndpointAdapter.kt
@@ -23,17 +23,20 @@ import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.content.res.AppCompatResources
+import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.celzero.bravedns.R
+import com.celzero.bravedns.util.SelectionIndicator
import com.celzero.bravedns.customdownloader.IpInfoDownloader
import com.celzero.bravedns.data.AppConfig
import com.celzero.bravedns.database.DnsProxyEndpoint
import com.celzero.bravedns.databinding.DnsProxyListItemBinding
import com.celzero.bravedns.service.FirewallManager
+import com.celzero.bravedns.service.PersistentState
import com.celzero.bravedns.service.IpRulesManager
import com.celzero.bravedns.service.VpnController
import com.celzero.bravedns.util.UIUtils.clipboardCopy
@@ -47,7 +50,8 @@ import kotlinx.coroutines.withContext
class DnsProxyEndpointAdapter(
private val context: Context,
val lifecycleOwner: LifecycleOwner,
- private val appConfig: AppConfig
+ private val appConfig: AppConfig,
+ private val persistentState: PersistentState
) :
PagingDataAdapter(
DIFF_CALLBACK
@@ -87,6 +91,8 @@ class DnsProxyEndpointAdapter(
inner class DnsProxyEndpointViewHolder(private val b: DnsProxyListItemBinding) :
RecyclerView.ViewHolder(b.root) {
+ private val selectionIndicator =
+ SelectionIndicator(b.dnsProxyListSelectionOrbital, b.dnsProxyListSelectionPill)
fun update(endpoint: DnsProxyEndpoint) {
displayDetails(endpoint)
@@ -94,22 +100,29 @@ class DnsProxyEndpointAdapter(
}
private fun setupClickListeners(endpoint: DnsProxyEndpoint) {
- b.root.setOnClickListener { updateDnsProxyDetails(endpoint) }
-
- b.dnsProxyListActionImage.setOnClickListener { promptUser(endpoint) }
-
- b.dnsProxyListCheckImage.setOnClickListener { updateDnsProxyDetails(endpoint) }
-
- b.root.setOnClickListener { updateDnsProxyDetails(endpoint) }
+ b.root.setOnClickListener { selectDnsProxy(endpoint) }
b.dnsProxyListActionImage.setOnClickListener { promptUser(endpoint) }
+ }
- b.dnsProxyListCheckImage.setOnClickListener { updateDnsProxyDetails(endpoint) }
+ private fun selectDnsProxy(endpoint: DnsProxyEndpoint) {
+ if (isProxyLockdownConflict(endpoint)) {
+ showLockdownConflictDialog(endpoint)
+ return
+ }
+ updateDnsProxyDetails(endpoint)
}
private fun displayDetails(endpoint: DnsProxyEndpoint) {
b.dnsProxyListUrlName.text = endpoint.proxyName
- b.dnsProxyListCheckImage.isChecked = endpoint.isSelected
+ b.root.contentDescription =
+ context.getString(
+ if (endpoint.isSelected) R.string.dns_list_item_selected_cd
+ else R.string.dns_list_item_select_cd,
+ endpoint.proxyName
+ )
+ selectionIndicator.update(endpoint.isSelected)
+
io {
val appInfo = FirewallManager.getAppInfoByPackage(endpoint.proxyAppName)
@@ -250,6 +263,33 @@ class DnsProxyEndpointAdapter(
}
}
+ private fun isProxyLockdownConflict(endpoint: DnsProxyEndpoint): Boolean {
+ if (!persistentState.wgGlobalLockdown) return false
+ val app = endpoint.proxyAppName
+ return !app.isNullOrBlank() &&
+ app != context.getString(R.string.cd_custom_dns_proxy_default_app)
+ }
+
+ private fun showLockdownConflictDialog(endpoint: DnsProxyEndpoint) {
+ io {
+ val appName =
+ FirewallManager.getAppInfoByPackage(endpoint.proxyAppName)?.appName
+ ?: endpoint.proxyAppName
+ ?: context.getString(R.string.cd_custom_dns_proxy_default_app)
+ uiCtx {
+ MaterialAlertDialogBuilder(context)
+ .setTitle(R.string.lockdown_check_dialog_title)
+ .setMessage(
+ context.getString(R.string.dns_proxy_lockdown_conflict_message, appName)
+ )
+ .setCancelable(true)
+ .setPositiveButton(R.string.dns_info_positive) { d, _ -> d.dismiss() }
+ .create()
+ .show()
+ }
+ }
+ }
+
private fun deleteProxyEndpoint(id: Int) {
io {
appConfig.deleteDnsProxyEndpoint(id)
@@ -264,7 +304,15 @@ class DnsProxyEndpointAdapter(
}
private suspend fun uiCtx(f: suspend () -> Unit) {
- withContext(Dispatchers.Main) { f() }
+ val owner = lifecycleOwner
+
+ withContext(Dispatchers.Main.immediate) {
+ if (!owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
+ return@withContext
+ }
+
+ f()
+ }
}
private fun io(f: suspend () -> Unit) {
diff --git a/app/src/main/java/com/celzero/bravedns/adapter/DoTEndpointAdapter.kt b/app/src/main/java/com/celzero/bravedns/adapter/DoTEndpointAdapter.kt
index 08d3f8e7eb..9377acc5fc 100644
--- a/app/src/main/java/com/celzero/bravedns/adapter/DoTEndpointAdapter.kt
+++ b/app/src/main/java/com/celzero/bravedns/adapter/DoTEndpointAdapter.kt
@@ -25,6 +25,7 @@ import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.content.ContextCompat
+import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.lifecycleScope
@@ -32,6 +33,7 @@ import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.celzero.bravedns.R
+import com.celzero.bravedns.util.SelectionIndicator
import com.celzero.bravedns.customdownloader.IpInfoDownloader
import com.celzero.bravedns.data.AppConfig
import com.celzero.bravedns.database.DoTEndpoint
@@ -91,6 +93,9 @@ class DoTEndpointAdapter(private val context: Context, private val appConfig: Ap
inner class DoTEndpointViewHolder(private val b: ListItemEndpointBinding) :
RecyclerView.ViewHolder(b.root) {
private var statusCheckJob: Job? = null
+ private val selectionIndicator =
+ SelectionIndicator(b.endpointSelectionOrbital, b.endpointSelectionPill)
+
fun update(endpoint: DoTEndpoint) {
displayDetails(endpoint)
@@ -100,7 +105,6 @@ class DoTEndpointAdapter(private val context: Context, private val appConfig: Ap
private fun setupClickListeners(endpoint: DoTEndpoint) {
b.root.setOnClickListener { updateConnection(endpoint) }
b.endpointInfoImg.setOnClickListener { showExplanationOnImageClick(endpoint) }
- b.endpointCheck.setOnClickListener { updateConnection(endpoint) }
}
private fun displayDetails(endpoint: DoTEndpoint) {
@@ -114,7 +118,13 @@ class DoTEndpointAdapter(private val context: Context, private val appConfig: Ap
context.getString(R.string.lbl_insecure)
)
}
- b.endpointCheck.isChecked = endpoint.isSelected
+ b.root.contentDescription =
+ context.getString(
+ if (endpoint.isSelected) R.string.dns_list_item_selected_cd
+ else R.string.dns_list_item_select_cd,
+ b.endpointName.text
+ )
+ selectionIndicator.update(endpoint.isSelected)
if (endpoint.isSelected && VpnController.hasTunnel() && !appConfig.isSmartDnsEnabled()) {
keepSelectedStatusUpdated()
@@ -297,7 +307,15 @@ class DoTEndpointAdapter(private val context: Context, private val appConfig: Ap
}
private suspend fun uiCtx(f: suspend () -> Unit) {
- withContext(Dispatchers.Main) { f() }
+ val owner = lifecycleOwner ?: return
+
+ withContext(Dispatchers.Main.immediate) {
+ if (!owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
+ return@withContext
+ }
+
+ f()
+ }
}
private fun ui(f: suspend () -> Unit): Job? {
diff --git a/app/src/main/java/com/celzero/bravedns/adapter/DohEndpointAdapter.kt b/app/src/main/java/com/celzero/bravedns/adapter/DohEndpointAdapter.kt
index 54d81f8285..9f66e6c20b 100644
--- a/app/src/main/java/com/celzero/bravedns/adapter/DohEndpointAdapter.kt
+++ b/app/src/main/java/com/celzero/bravedns/adapter/DohEndpointAdapter.kt
@@ -26,6 +26,7 @@ import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.content.ContextCompat
+import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.lifecycleScope
@@ -33,6 +34,7 @@ import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.celzero.bravedns.R
+import com.celzero.bravedns.util.SelectionIndicator
import com.celzero.bravedns.customdownloader.IpInfoDownloader
import com.celzero.bravedns.data.AppConfig
import com.celzero.bravedns.database.DoHEndpoint
@@ -93,6 +95,8 @@ class DohEndpointAdapter(private val context: Context, private val appConfig: Ap
inner class DoHEndpointViewHolder(private val b: ListItemEndpointBinding) :
RecyclerView.ViewHolder(b.root) {
private var statusCheckJob: Job? = null
+ private val selectionIndicator =
+ SelectionIndicator(b.endpointSelectionOrbital, b.endpointSelectionPill)
fun update(endpoint: DoHEndpoint) {
displayDetails(endpoint)
@@ -102,7 +106,6 @@ class DohEndpointAdapter(private val context: Context, private val appConfig: Ap
private fun setupClickListeners(endpoint: DoHEndpoint) {
b.root.setOnClickListener { updateConnection(endpoint) }
b.endpointInfoImg.setOnClickListener { showExplanationOnImageClick(endpoint) }
- b.endpointCheck.setOnClickListener { updateConnection(endpoint) }
}
private fun displayDetails(endpoint: DoHEndpoint) {
@@ -116,7 +119,13 @@ class DohEndpointAdapter(private val context: Context, private val appConfig: Ap
context.getString(R.string.lbl_insecure)
)
}
- b.endpointCheck.isChecked = endpoint.isSelected
+ b.root.contentDescription =
+ context.getString(
+ if (endpoint.isSelected) R.string.dns_list_item_selected_cd
+ else R.string.dns_list_item_select_cd,
+ b.endpointName.text
+ )
+ selectionIndicator.update(endpoint.isSelected)
if (endpoint.isSelected && VpnController.hasTunnel() && !appConfig.isSmartDnsEnabled()) {
keepSelectedStatusUpdated()
} else if (endpoint.isSelected) {
@@ -207,13 +216,14 @@ class DohEndpointAdapter(private val context: Context, private val appConfig: Ap
private fun showExplanationOnImageClick(endpoint: DoHEndpoint) {
if (endpoint.isDeletable()) showDeleteDnsDialog(endpoint.id)
- else showDohMetadataDialog(endpoint.dohName, endpoint.dohURL, endpoint.dohExplanation)
+ else showDohMetadataDialog(endpoint.dohName, endpoint.dohURL, endpoint.dohIp, endpoint.dohExplanation)
}
- private fun showDohMetadataDialog(title: String, url: String, message: String?) {
+ private fun showDohMetadataDialog(title: String, url: String, ips: String?, message: String?) {
val builder = MaterialAlertDialogBuilder(context, R.style.App_Dialog_NoDim)
builder.setTitle(title)
- builder.setMessage(url + "\n\n" + getDnsDesc(message))
+ val msg = url + if (!ips.isNullOrEmpty()) "\n\n$ips" else "" + "\n\n" + getDnsDesc(message)
+ builder.setMessage(msg)
builder.setCancelable(true)
builder.setPositiveButton(context.getString(R.string.dns_info_positive)) { dialogInterface, _ ->
dialogInterface.dismiss()
@@ -298,7 +308,15 @@ class DohEndpointAdapter(private val context: Context, private val appConfig: Ap
}
private suspend fun uiCtx(f: suspend () -> Unit) {
- withContext(Dispatchers.Main) { f() }
+ val owner = lifecycleOwner ?: return
+
+ withContext(Dispatchers.Main.immediate) {
+ if (!owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
+ return@withContext
+ }
+
+ f()
+ }
}
private fun ui(f: suspend () -> Unit): Job? {
diff --git a/app/src/main/java/com/celzero/bravedns/adapter/DomainConnectionsAdapter.kt b/app/src/main/java/com/celzero/bravedns/adapter/DomainConnectionsAdapter.kt
index c76385dd06..67d57e3eb6 100644
--- a/app/src/main/java/com/celzero/bravedns/adapter/DomainConnectionsAdapter.kt
+++ b/app/src/main/java/com/celzero/bravedns/adapter/DomainConnectionsAdapter.kt
@@ -21,6 +21,7 @@ import android.graphics.drawable.Drawable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.paging.PagingDataAdapter
@@ -172,6 +173,14 @@ class DomainConnectionsAdapter(private val context: Context, private val type: D
}
private suspend fun uiCtx(f: suspend () -> Unit) {
- withContext(Dispatchers.Main) { f() }
+ val owner = context as? LifecycleOwner ?: return
+
+ withContext(Dispatchers.Main.immediate) {
+ if (!owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
+ return@withContext
+ }
+
+ f()
+ }
}
}
diff --git a/app/src/main/java/com/celzero/bravedns/adapter/FirewallAppListAdapter.kt b/app/src/main/java/com/celzero/bravedns/adapter/FirewallAppListAdapter.kt
index 3755817213..841f0bbec9 100644
--- a/app/src/main/java/com/celzero/bravedns/adapter/FirewallAppListAdapter.kt
+++ b/app/src/main/java/com/celzero/bravedns/adapter/FirewallAppListAdapter.kt
@@ -27,12 +27,12 @@ import android.widget.ArrayAdapter
import android.widget.ImageView
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
+import androidx.lifecycle.Lifecycle
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.database.AppInfo
import com.celzero.bravedns.database.EventSource
@@ -125,11 +125,11 @@ class FirewallAppListAdapter(
b.firewallAppLabelTv.setTextColor(userAppColor)
} */
b.firewallAppLabelTv.text = appInfo.appName
- b.firewallAppInfo.text = if (appInfo.packageName.startsWith(NO_PACKAGE_PREFIX)) {
+ /*b.firewallAppInfo.text = if (appInfo.packageName.startsWith(NO_PACKAGE_PREFIX)) {
context.getString(R.string.app_id_uid_only, appInfo.uid)
} else {
context.getString(R.string.app_id_package, appInfo.uid, appInfo.packageName)
- }
+ }*/
b.firewallAppToggleOther.text = getFirewallText(appStatus, connStatus)
displayIcon(
getIcon(context, appInfo.packageName, appInfo.appName), b.firewallAppIconIv)
@@ -291,12 +291,10 @@ class FirewallAppListAdapter(
}
private fun displayIcon(drawable: Drawable?, mIconImageView: ImageView) {
- ui {
- Glide.with(context)
- .load(drawable)
- .error(Utilities.getDefaultIcon(context))
- .into(mIconImageView)
- }
+ val target = drawable ?: Utilities.getDefaultIcon(context) ?: return
+ val current = mIconImageView.drawable?.constantState
+ if (current != null && current == target.constantState) return
+ mIconImageView.setImageDrawable(target)
}
private fun setupClickListeners(appInfo: AppInfo) {
@@ -482,11 +480,15 @@ class FirewallAppListAdapter(
}
private suspend fun uiCtx(f: suspend () -> Unit) {
- withContext(Dispatchers.Main) { f() }
- }
+ val owner = context as? LifecycleOwner ?: return
- private fun ui(f: suspend () -> Unit) {
- lifecycleOwner.lifecycleScope.launch { withContext(Dispatchers.Main) { f() } }
+ withContext(Dispatchers.Main.immediate) {
+ if (!owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
+ return@withContext
+ }
+
+ f()
+ }
}
private fun io(f: suspend () -> Unit) {
diff --git a/app/src/main/java/com/celzero/bravedns/adapter/GenericHopAdapter.kt b/app/src/main/java/com/celzero/bravedns/adapter/GenericHopAdapter.kt
index 26b57a16e9..906ca28d1e 100644
--- a/app/src/main/java/com/celzero/bravedns/adapter/GenericHopAdapter.kt
+++ b/app/src/main/java/com/celzero/bravedns/adapter/GenericHopAdapter.kt
@@ -22,6 +22,7 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
+import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
@@ -520,7 +521,15 @@ class GenericHopAdapter(
}
private suspend fun uiCtx(f: suspend () -> Unit) {
- withContext(Dispatchers.Main) { f() }
+ val owner = lifecycleOwner
+
+ withContext(Dispatchers.Main.immediate) {
+ if (!owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
+ return@withContext
+ }
+
+ f()
+ }
}
}
}
diff --git a/app/src/main/java/com/celzero/bravedns/adapter/ODoHEndpointAdapter.kt b/app/src/main/java/com/celzero/bravedns/adapter/ODoHEndpointAdapter.kt
index 69f8b1848a..de136083f6 100644
--- a/app/src/main/java/com/celzero/bravedns/adapter/ODoHEndpointAdapter.kt
+++ b/app/src/main/java/com/celzero/bravedns/adapter/ODoHEndpointAdapter.kt
@@ -25,6 +25,7 @@ import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.content.ContextCompat
+import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.lifecycleScope
@@ -32,6 +33,7 @@ import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.celzero.bravedns.R
+import com.celzero.bravedns.util.SelectionIndicator
import com.celzero.bravedns.customdownloader.IpInfoDownloader
import com.celzero.bravedns.data.AppConfig
import com.celzero.bravedns.database.ODoHEndpoint
@@ -92,6 +94,9 @@ class ODoHEndpointAdapter(private val context: Context, private val appConfig: A
inner class ODoHEndpointViewHolder(private val b: ListItemEndpointBinding) :
RecyclerView.ViewHolder(b.root) {
private var statusCheckJob: Job? = null
+ private val selectionIndicator =
+ SelectionIndicator(b.endpointSelectionOrbital, b.endpointSelectionPill)
+
fun update(endpoint: ODoHEndpoint) {
displayDetails(endpoint)
@@ -101,12 +106,17 @@ class ODoHEndpointAdapter(private val context: Context, private val appConfig: A
private fun setupClickListeners(endpoint: ODoHEndpoint) {
b.root.setOnClickListener { updateConnection(endpoint) }
b.endpointInfoImg.setOnClickListener { showExplanationOnImageClick(endpoint) }
- b.endpointCheck.setOnClickListener { updateConnection(endpoint) }
}
private fun displayDetails(endpoint: ODoHEndpoint) {
b.endpointName.text = endpoint.name
- b.endpointCheck.isChecked = endpoint.isSelected
+ b.root.contentDescription =
+ context.getString(
+ if (endpoint.isSelected) R.string.dns_list_item_selected_cd
+ else R.string.dns_list_item_select_cd,
+ endpoint.name
+ )
+ selectionIndicator.update(endpoint.isSelected)
if (endpoint.isSelected && VpnController.hasTunnel() && !appConfig.isSmartDnsEnabled()) {
keepSelectedStatusUpdated()
@@ -302,7 +312,15 @@ class ODoHEndpointAdapter(private val context: Context, private val appConfig: A
}
private suspend fun uiCtx(f: suspend () -> Unit) {
- withContext(Dispatchers.Main) { f() }
+ val owner = lifecycleOwner ?: return
+
+ withContext(Dispatchers.Main.immediate) {
+ if (!owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
+ return@withContext
+ }
+
+ f()
+ }
}
private fun ui(f: suspend () -> Unit): Job? {
diff --git a/app/src/main/java/com/celzero/bravedns/adapter/OneWgConfigAdapter.kt b/app/src/main/java/com/celzero/bravedns/adapter/OneWgConfigAdapter.kt
index ef4c440b1f..d71fc5a412 100644
--- a/app/src/main/java/com/celzero/bravedns/adapter/OneWgConfigAdapter.kt
+++ b/app/src/main/java/com/celzero/bravedns/adapter/OneWgConfigAdapter.kt
@@ -25,6 +25,7 @@ import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.view.isVisible
+import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.lifecycleScope
@@ -343,7 +344,12 @@ class OneWgConfigAdapter(private val context: Context, private val listener: Dns
if (ip.isNullOrBlank()) {
val c = WireguardManager.getConfigById(configId)
- val host = c?.getPeers()?.getOrNull(0)?.getEndpoint()?.orElse(null)?.host
+ val host =
+ c?.getPeers()
+ ?.getOrNull(0)
+ ?.getEndpoint()
+ ?.orElse(null)
+ ?.let { stripPort(it) }
if (!host.isNullOrBlank() && HostName(host).asAddress() != null) {
ip = host
}
@@ -559,7 +565,15 @@ class OneWgConfigAdapter(private val context: Context, private val listener: Dns
}
private suspend fun uiCtx(f: suspend () -> Unit) {
- withContext(Dispatchers.Main) { f() }
+ val owner = context as? LifecycleOwner ?: return
+
+ withContext(Dispatchers.Main.immediate) {
+ if (!owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
+ return@withContext
+ }
+
+ f()
+ }
}
private fun io(f: suspend () -> Unit): Job? {
diff --git a/app/src/main/java/com/celzero/bravedns/adapter/PingTestHistoryAdapter.kt b/app/src/main/java/com/celzero/bravedns/adapter/PingTestHistoryAdapter.kt
new file mode 100644
index 0000000000..6a0120bb74
--- /dev/null
+++ b/app/src/main/java/com/celzero/bravedns/adapter/PingTestHistoryAdapter.kt
@@ -0,0 +1,147 @@
+/*
+ * Copyright 2026 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.res.ColorStateList
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.core.content.ContextCompat
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import com.celzero.bravedns.R
+import com.celzero.bravedns.databinding.ItemPingTestHistoryBinding
+import com.celzero.bravedns.rpnproxy.RpnProxyManager
+import com.celzero.bravedns.rpnproxy.RpnProxyManager.PingTestHistoryEntry
+import com.celzero.bravedns.util.UIUtils
+
+/**
+ * Renders the recent RPN reachability-test history maintained by
+ * [RpnProxyManager.pingTestHistory] inside PingTestActivity.
+ */
+class PingTestHistoryAdapter(private val context: Context) :
+ ListAdapter(DIFF_CALLBACK) {
+
+ companion object {
+ private val DIFF_CALLBACK =
+ object : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(
+ oldItem: PingTestHistoryEntry,
+ newItem: PingTestHistoryEntry
+ ): Boolean {
+ return oldItem.timestamp == newItem.timestamp
+ }
+
+ override fun areContentsTheSame(
+ oldItem: PingTestHistoryEntry,
+ newItem: PingTestHistoryEntry
+ ): Boolean {
+ return oldItem == newItem
+ }
+ }
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HistoryViewHolder {
+ val binding =
+ ItemPingTestHistoryBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ return HistoryViewHolder(binding)
+ }
+
+ override fun onBindViewHolder(holder: HistoryViewHolder, position: Int) {
+ holder.update(getItem(position))
+ }
+
+ inner class HistoryViewHolder(private val b: ItemPingTestHistoryBinding) :
+ RecyclerView.ViewHolder(b.root) {
+
+ fun update(entry: PingTestHistoryEntry) {
+ displayStatus(entry)
+ displayDetails(entry)
+ }
+
+ private fun displayStatus(entry: PingTestHistoryEntry) {
+ when (entry.outcomeEnum()) {
+ RpnProxyManager.PingTestOutcome.SUCCESS -> {
+ b.historyIcon.setImageResource(R.drawable.ic_tick)
+ b.historyIcon.backgroundTintList =
+ ColorStateList.valueOf(
+ UIUtils.fetchColor(context, R.attr.colorSurfaceVariant)
+ )
+ b.historyIcon.imageTintList =
+ ColorStateList.valueOf(
+ ContextCompat.getColor(context, R.color.accentGood)
+ )
+ b.historyOutcome.text = context.getString(R.string.ping_reach_reachable)
+ b.historyOutcome.setTextColor(
+ ContextCompat.getColor(context, R.color.accentGood)
+ )
+ }
+ RpnProxyManager.PingTestOutcome.PARTIAL -> {
+ b.historyIcon.setImageResource(R.drawable.ic_cross_accent)
+ b.historyIcon.backgroundTintList =
+ ColorStateList.valueOf(
+ UIUtils.fetchColor(context, R.attr.colorSurfaceVariant)
+ )
+ b.historyIcon.imageTintList =
+ ColorStateList.valueOf(
+ UIUtils.fetchColor(context, R.attr.accentWarning)
+ )
+ b.historyOutcome.text = context.getString(R.string.ping_partial_title)
+ b.historyOutcome.setTextColor(
+ UIUtils.fetchColor(context, R.attr.accentWarning)
+ )
+ }
+ RpnProxyManager.PingTestOutcome.FAILURE -> {
+ b.historyIcon.setImageResource(R.drawable.ic_cross_accent)
+ b.historyIcon.backgroundTintList =
+ ColorStateList.valueOf(
+ UIUtils.fetchColor(context, R.attr.colorSurfaceVariant)
+ )
+ b.historyIcon.imageTintList =
+ ColorStateList.valueOf(
+ ContextCompat.getColor(context, R.color.accentBad)
+ )
+ b.historyOutcome.text = context.getString(R.string.ping_failure_title)
+ b.historyOutcome.setTextColor(
+ ContextCompat.getColor(context, R.color.accentBad)
+ )
+ }
+ }
+ }
+
+ private fun displayDetails(entry: PingTestHistoryEntry) {
+ b.historyTarget.text = displayTarget(entry)
+
+ val time = UIUtils.getRelativeTimeSpan(entry.timestamp) ?: ""
+ b.historyMeta.text = if (entry.total > 1) {
+ context.getString(R.string.ping_history_meta_passed, time, entry.passed, entry.total)
+ } else {
+ time
+ }
+
+ b.historyLatency.text = context.getString(R.string.ping_total_latency, entry.latencyMs)
+ }
+
+ private fun displayTarget(entry: PingTestHistoryEntry): String {
+ return if (entry.isAuto()) {
+ context.getString(R.string.ping_history_auto_targets)
+ } else {
+ entry.targets
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/celzero/bravedns/adapter/RethinkEndpointAdapter.kt b/app/src/main/java/com/celzero/bravedns/adapter/RethinkEndpointAdapter.kt
index 687692a4dc..826d0b7ebb 100644
--- a/app/src/main/java/com/celzero/bravedns/adapter/RethinkEndpointAdapter.kt
+++ b/app/src/main/java/com/celzero/bravedns/adapter/RethinkEndpointAdapter.kt
@@ -26,6 +26,7 @@ import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.content.ContextCompat
+import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.lifecycleScope
@@ -33,6 +34,7 @@ import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.celzero.bravedns.R
+import com.celzero.bravedns.util.SelectionIndicator
import com.celzero.bravedns.customdownloader.IpInfoDownloader
import com.celzero.bravedns.data.AppConfig
import com.celzero.bravedns.database.RethinkDnsEndpoint
@@ -103,6 +105,11 @@ class RethinkEndpointAdapter(private val context: Context, private val appConfig
inner class RethinkEndpointViewHolder(private val b: RethinkEndpointListItemBinding) :
RecyclerView.ViewHolder(b.root) {
private var statusCheckJob: Job? = null
+ private val selectionIndicator =
+ SelectionIndicator(
+ b.rethinkEndpointListSelectionOrbital,
+ b.rethinkEndpointListSelectionPill
+ )
fun update(endpoint: RethinkDnsEndpoint) {
displayDetails(endpoint)
@@ -112,12 +119,18 @@ class RethinkEndpointAdapter(private val context: Context, private val appConfig
private fun setupClickListeners(endpoint: RethinkDnsEndpoint) {
b.root.setOnClickListener { updateConnection(endpoint) }
b.rethinkEndpointListActionImage.setOnClickListener { showDohMetadataDialog(endpoint) }
- b.rethinkEndpointListCheckImage.setOnClickListener { updateConnection(endpoint) }
}
private fun displayDetails(endpoint: RethinkDnsEndpoint) {
b.rethinkEndpointListUrlName.text = endpoint.name
- b.rethinkEndpointListCheckImage.isChecked = endpoint.isActive
+ b.root.contentDescription =
+ context.getString(
+ if (endpoint.isActive) R.string.dns_list_item_selected_cd
+ else R.string.dns_list_item_select_cd,
+ endpoint.name
+ )
+ selectionIndicator.update(endpoint.isActive)
+
// Shows either the info/delete icon for the DoH entries.
showIcon(endpoint)
@@ -307,7 +320,15 @@ class RethinkEndpointAdapter(private val context: Context, private val appConfig
}
private suspend fun uiCtx(f: suspend () -> Unit) {
- withContext(Dispatchers.Main) { f() }
+ val owner = lifecycleOwner ?: return
+
+ withContext(Dispatchers.Main.immediate) {
+ if (!owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
+ return@withContext
+ }
+
+ f()
+ }
}
private fun io(f: suspend () -> Unit): Job? {
diff --git a/app/src/main/java/com/celzero/bravedns/adapter/ServerWgPeersAdapter.kt b/app/src/main/java/com/celzero/bravedns/adapter/ServerWgPeersAdapter.kt
index 7930039a7b..a08f8867c5 100644
--- a/app/src/main/java/com/celzero/bravedns/adapter/ServerWgPeersAdapter.kt
+++ b/app/src/main/java/com/celzero/bravedns/adapter/ServerWgPeersAdapter.kt
@@ -75,7 +75,7 @@ class ServerWgPeersAdapter(
// Show endpoint
if (wgPeer.getEndpoint().isPresent) {
- b.endpointText.text = wgPeer.getEndpoint().get().toString()
+ b.endpointText.text = wgPeer.getEndpoint().get()
b.endpointLabel.visibility = View.VISIBLE
b.endpointText.visibility = View.VISIBLE
} else {
diff --git a/app/src/main/java/com/celzero/bravedns/adapter/SmartDnsEndpointAdapter.kt b/app/src/main/java/com/celzero/bravedns/adapter/SmartDnsEndpointAdapter.kt
new file mode 100644
index 0000000000..80f53473b1
--- /dev/null
+++ b/app/src/main/java/com/celzero/bravedns/adapter/SmartDnsEndpointAdapter.kt
@@ -0,0 +1,119 @@
+/*
+ * Copyright 2026 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.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import com.celzero.bravedns.R
+import com.celzero.bravedns.util.SelectionIndicator
+import com.celzero.bravedns.database.SmartDnsEndpoint
+import com.celzero.bravedns.database.SmartDnsMode
+import com.celzero.bravedns.databinding.ListItemEndpointBinding
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+
+class SmartDnsEndpointAdapter(
+ private val context: Context,
+ private val isSmartDnsActive: () -> Boolean
+) :
+ ListAdapter(DIFF_CALLBACK) {
+
+ companion object {
+ private val DIFF_CALLBACK =
+ object : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(
+ oldEndpoint: SmartDnsEndpoint,
+ newEndpoint: SmartDnsEndpoint
+ ): Boolean {
+ return oldEndpoint.id == newEndpoint.id
+ }
+
+ override fun areContentsTheSame(
+ oldEndpoint: SmartDnsEndpoint,
+ newEndpoint: SmartDnsEndpoint
+ ): Boolean {
+ return oldEndpoint == newEndpoint
+ }
+ }
+ }
+
+ var onEndpointSelected: ((SmartDnsEndpoint) -> Unit)? = null
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SmartDnsEndpointViewHolder {
+ val itemBinding =
+ ListItemEndpointBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ return SmartDnsEndpointViewHolder(itemBinding)
+ }
+
+ override fun onBindViewHolder(holder: SmartDnsEndpointViewHolder, position: Int) {
+ holder.update(getItem(position))
+ }
+
+ inner class SmartDnsEndpointViewHolder(private val b: ListItemEndpointBinding) :
+ RecyclerView.ViewHolder(b.root) {
+ private val selectionIndicator =
+ SelectionIndicator(b.endpointSelectionOrbital, b.endpointSelectionPill)
+
+
+ fun update(endpoint: SmartDnsEndpoint) {
+ displayDetails(endpoint)
+ setupClickListeners(endpoint)
+ }
+
+ private fun setupClickListeners(endpoint: SmartDnsEndpoint) {
+ b.root.setOnClickListener { onEndpointSelected?.invoke(endpoint) }
+ b.endpointInfoImg.setOnClickListener { showExplanationDialog(endpoint) }
+ }
+
+ private fun displayDetails(endpoint: SmartDnsEndpoint) {
+ b.endpointName.text = endpoint.dnsName
+ val isSelected = endpoint.isSelected && isSmartDnsActive()
+ b.root.contentDescription =
+ context.getString(
+ if (isSelected) R.string.dns_list_item_selected_cd
+ else R.string.dns_list_item_select_cd,
+ endpoint.dnsName
+ )
+ selectionIndicator.update(isSelected)
+ if (isSelected) {
+ b.endpointDesc.text = context.getString(R.string.rt_filter_parent_selected)
+ b.endpointDesc.visibility = View.VISIBLE
+ } else {
+ b.endpointDesc.visibility = View.GONE
+ }
+ // the info icon shows the explanation; there is no delete action as all
+ // smart dns entries are default (non-custom)
+ b.endpointInfoImg.setImageResource(R.drawable.ic_info)
+ // flag text is not applicable for smart dns options
+ b.endpointFlagText.visibility = View.GONE
+ }
+
+ private fun showExplanationDialog(endpoint: SmartDnsEndpoint) {
+ val builder = MaterialAlertDialogBuilder(context, R.style.App_Dialog_NoDim)
+ builder.setTitle(endpoint.dnsName)
+ builder.setMessage(endpoint.dnsExplanation)
+ builder.setCancelable(true)
+ builder.setPositiveButton(context.getString(R.string.dns_info_positive)) { dialog, _ ->
+ dialog.dismiss()
+ }
+ builder.create().show()
+ }
+ }
+}
diff --git a/app/src/main/java/com/celzero/bravedns/adapter/SummaryStatisticsAdapter.kt b/app/src/main/java/com/celzero/bravedns/adapter/SummaryStatisticsAdapter.kt
index 4a1b265468..113c09a177 100644
--- a/app/src/main/java/com/celzero/bravedns/adapter/SummaryStatisticsAdapter.kt
+++ b/app/src/main/java/com/celzero/bravedns/adapter/SummaryStatisticsAdapter.kt
@@ -25,6 +25,7 @@ import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.Toast
+import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.paging.PagingDataAdapter
@@ -68,38 +69,54 @@ class SummaryStatisticsAdapter(
private val type: SummaryStatisticsType
) :
PagingDataAdapter(
- DIFF_CALLBACK
+ diffCallback(type)
) {
- private var maxValue: Int = 0
private var timeCategory = SummaryStatisticsViewModel.TimeCategory.ONE_HOUR
+ // per-uid identity caches: FirewallManager lookups and icon resolution run
+ // once per uid; every later bind (the common case while paging updates
+ // stream in) resolves synchronously, so rebinds never flicker or bleed
+ // content from another row/section
+ private val appNameByUid = HashMap()
+ private val appIconByUid = HashMap()
+
companion object {
private const val PERCENTAGE_MULTIPLIER = 100
- private val DIFF_CALLBACK =
+ private fun diffCallback(type: SummaryStatisticsType): DiffUtil.ItemCallback =
object : DiffUtil.ItemCallback() {
- // Fix: Compare by unique identifiers instead of object equality
- // to prevent RecyclerView position inconsistencies
override fun areItemsTheSame(old: AppConnection, new: AppConnection): Boolean {
- return old.uid == new.uid &&
- old.ipAddress == new.ipAddress &&
- old.port == new.port
+ return keyOf(old, type) == keyOf(new, type)
}
override fun areContentsTheSame(old: AppConnection, new: AppConnection): Boolean {
- return old.uid == new.uid &&
- old.ipAddress == new.ipAddress &&
- old.port == new.port &&
- old.count == new.count &&
- old.flag == new.flag &&
- old.blocked == new.blocked &&
- old.appOrDnsName == new.appOrDnsName &&
- old.downloadBytes == new.downloadBytes &&
- old.uploadBytes == new.uploadBytes &&
- old.totalBytes == new.totalBytes
+ // AppConnection is a data class; full equality covers every
+ // field rendered by bind()
+ return old == new
}
}
+
+ /**
+ * Stable per-type identity for DiffUtil. Several queries (domains, ASN,
+ * countries) aggregate with constant uid/ip/port, so uid+ip+port would
+ * give every row the same identity and diffing across tab switches
+ * would misapply updates.
+ */
+ private fun keyOf(item: AppConnection, type: SummaryStatisticsType): String {
+ return when (type) {
+ SummaryStatisticsType.MOST_CONNECTED_APPS,
+ SummaryStatisticsType.MOST_BLOCKED_APPS,
+ SummaryStatisticsType.TOP_ACTIVE_CONNS -> "uid:${item.uid}"
+ SummaryStatisticsType.MOST_CONNECTED_ASN,
+ SummaryStatisticsType.MOST_BLOCKED_ASN,
+ SummaryStatisticsType.MOST_CONTACTED_DOMAINS,
+ SummaryStatisticsType.MOST_BLOCKED_DOMAINS -> "name:${item.appOrDnsName.orEmpty()}"
+ SummaryStatisticsType.MOST_CONTACTED_COUNTRIES -> "flag:${item.flag}"
+ SummaryStatisticsType.MOST_CONTACTED_IPS,
+ SummaryStatisticsType.MOST_BLOCKED_IPS -> "ip:${item.uid}:${item.ipAddress}:${item.port}"
+ }
+ }
}
override fun onCreateViewHolder(
@@ -115,9 +132,16 @@ class SummaryStatisticsAdapter(
return AppNetworkActivityViewHolder(itemBinding)
}
+ override fun onViewRecycled(holder: AppNetworkActivityViewHolder) {
+ super.onViewRecycled(holder)
+ // cancel any in-flight favicon request so it cannot deliver into a
+ // recycled view that is (or will be) bound to a different item
+ Glide.with(context).clear(holder.itemBinding.ssIcon)
+ }
+
override fun onBindViewHolder(holder: AppNetworkActivityViewHolder, position: Int) {
// Fix: Validate position to prevent IndexOutOfBoundsException
- if (position < 0 || position >= itemCount) {
+ if (position !in 0.. maxValue) {
- maxValue = value
+ /**
+ * Deterministic, order-independent percentage: the maximum is recomputed
+ * from the current snapshot on every bind, so a maximum from a previous
+ * time window (tab) can never suppress new values.
+ */
+ private fun calculatePercentage(item: AppConnection): Int {
+ val current = (log2(progressValue(item)) * PERCENTAGE_MULTIPLIER).toInt()
+ var max = current
+ for (other in snapshot().items) {
+ val v = progressValue(other)
+ if (v > 0.0) {
+ val pv = (log2(v) * PERCENTAGE_MULTIPLIER).toInt()
+ if (pv > max) {
+ max = pv
+ }
+ }
}
- return if (maxValue == 0) {
- 0
+ return if (max == 0) 0 else (current * PERCENTAGE_MULTIPLIER / max)
+ }
+
+ private fun progressValue(item: AppConnection): Double {
+ return if (type == SummaryStatisticsType.MOST_CONNECTED_APPS) {
+ val d = item.downloadBytes ?: 0L
+ val u = item.uploadBytes ?: 0L
+ (d + u).toDouble()
} else {
- (value * PERCENTAGE_MULTIPLIER / maxValue)
+ item.count.toDouble()
}
}
@@ -143,18 +184,64 @@ class SummaryStatisticsAdapter(
}
inner class AppNetworkActivityViewHolder(
- private val itemBinding: ListItemStatisticsSummaryBinding
+ val itemBinding: ListItemStatisticsSummaryBinding
) : RecyclerView.ViewHolder(itemBinding.root) {
+ // guards async icon/name callbacks from a previous bind landing on a
+ // recycled view that has since been re-bound (tab switches rebind fast)
+ private var bindSeq: Long = 0L
+
+ private fun isStale(seq: Long): Boolean = seq != bindSeq
+
fun bind(appConnection: AppConnection) {
- setName(appConnection)
- io { setIcon(appConnection) }
+ val seq = ++bindSeq
+ // reset recycled state synchronously: a stale drawable from any
+ // previously bound item can never survive into this bind
+ itemBinding.ssIcon.setImageDrawable(null)
+ resolveAppIdentity(appConnection, seq)
+ setName(appConnection, seq)
+ setIcon(appConnection, seq)
showDataUsage(appConnection)
setProgress(appConnection)
setConnectionCount(appConnection)
setupClickListeners(appConnection)
}
+ /**
+ * Kicks off the (cached) app-name/icon resolution for app sections.
+ * First bind per uid resolves asynchronously; the result is cached and
+ * re-applied. All subsequent binds are fully synchronous.
+ */
+ private fun resolveAppIdentity(appConnection: AppConnection, seq: Long) {
+ if (type != SummaryStatisticsType.TOP_ACTIVE_CONNS &&
+ type != SummaryStatisticsType.MOST_CONNECTED_APPS &&
+ type != SummaryStatisticsType.MOST_BLOCKED_APPS
+ ) {
+ return
+ }
+ val uid = appConnection.uid
+ if (appNameByUid.containsKey(uid) && appIconByUid.containsKey(uid)) {
+ return
+ }
+ io {
+ val appInfo = FirewallManager.getAppInfoByUid(uid)
+ if (isStale(seq)) return@io
+ val icon = Utilities.getIcon(
+ context,
+ appInfo?.packageName.orEmpty(),
+ appInfo?.appName.orEmpty()
+ ) ?: Utilities.getDefaultIcon(context)
+ uiCtx {
+ if (isStale(seq)) return@uiCtx
+ appNameByUid[uid] = appInfo?.appName
+ appIconByUid[uid] = icon
+ // identity is now cached; re-run the synchronous appliers
+ setName(appConnection, seq)
+ setIcon(appConnection, seq)
+ }
+ }
+ }
+
private fun setConnectionCount(appConnection: AppConnection) {
itemBinding.ssCount.text = appConnection.count.toString()
}
@@ -188,184 +275,80 @@ class SummaryStatisticsAdapter(
itemBinding.ssCount.text = appConnection.count.toString()
}
- private suspend fun setIcon(appConnection: AppConnection) {
+ private fun setIcon(appConnection: AppConnection, seq: Long) {
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.orEmpty(),
- appInfo?.appName.orEmpty()
- )
- )
- }
- }
- }
- SummaryStatisticsType.MOST_CONNECTED_APPS -> {
- io {
- val appInfo = FirewallManager.getAppInfoByUid(appConnection.uid)
- uiCtx {
- itemBinding.ssIcon.visibility = View.VISIBLE
- itemBinding.ssFlag.visibility = View.GONE
- loadAppIcon(
- Utilities.getIcon(
- context,
- appInfo?.packageName.orEmpty(),
- appInfo?.appName.orEmpty()
- )
- )
- }
- }
- }
+ SummaryStatisticsType.TOP_ACTIVE_CONNS,
+ SummaryStatisticsType.MOST_CONNECTED_APPS,
SummaryStatisticsType.MOST_BLOCKED_APPS -> {
- io {
- val appInfo = FirewallManager.getAppInfoByUid(appConnection.uid)
- uiCtx {
- itemBinding.ssIcon.visibility = View.VISIBLE
- itemBinding.ssFlag.visibility = View.GONE
- loadAppIcon(
- Utilities.getIcon(
- context,
- appInfo?.packageName.orEmpty(),
- appInfo?.appName.orEmpty()
- )
- )
- }
- }
- }
- 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
- }
- }
+ // fully synchronous: the drawable is set directly, so no
+ // Glide request can ever deliver a stale icon into this row
+ val uid = appConnection.uid
+ val icon = appIconByUid[uid] ?: Utilities.getDefaultIcon(context)
+ itemBinding.ssIcon.visibility = View.VISIBLE
+ itemBinding.ssFlag.visibility = View.GONE
+ itemBinding.ssIcon.setImageDrawable(icon)
+ }
+ SummaryStatisticsType.MOST_CONNECTED_ASN,
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
- }
+ // synchronous: cheap text/visibility updates must never
+ // race rebinds on recycled views
+ itemBinding.ssIcon.visibility = View.GONE
+ itemBinding.ssFlag.visibility = View.VISIBLE
+ itemBinding.ssFlag.text =
+ if (appConnection.flag.isNotEmpty()) getFlag(appConnection.flag) else "--"
}
SummaryStatisticsType.MOST_CONTACTED_DOMAINS -> {
- uiCtx {
- itemBinding.ssFlag.text = appConnection.flag
- val query = appConnection.appOrDnsName?.dropLastWhile { it == ',' }
- if (query == null) {
- hideFavIcon()
- showFlag()
- return@uiCtx
- }
-
- // no need to check in glide cache if the value is available in failed
- // cache
- if (FavIconDownloader.isUrlAvailableInFailedCache(query) != 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(query)
- }
- }
- }
- SummaryStatisticsType.MOST_BLOCKED_DOMAINS -> {
- uiCtx {
- itemBinding.ssIcon.visibility = View.GONE
- itemBinding.ssFlag.visibility = View.VISIBLE
- itemBinding.ssFlag.text = appConnection.flag
+ // state flips run synchronously; Glide cancels the previous
+ // per-view request when a new favicon request is bound
+ itemBinding.ssIcon.visibility = View.GONE
+ itemBinding.ssFlag.text = appConnection.flag
+ val query = appConnection.appOrDnsName?.dropLastWhile { it == ',' }
+ if (query == null) {
+ hideFavIcon()
+ showFlag()
+ return
}
- }
- SummaryStatisticsType.MOST_CONTACTED_IPS -> {
- uiCtx {
- itemBinding.ssIcon.visibility = View.GONE
- itemBinding.ssFlag.visibility = View.VISIBLE
- itemBinding.ssFlag.text = appConnection.flag
- }
- }
- SummaryStatisticsType.MOST_BLOCKED_IPS -> {
- uiCtx {
- itemBinding.ssIcon.visibility = View.GONE
- itemBinding.ssFlag.visibility = View.VISIBLE
- itemBinding.ssFlag.text = appConnection.flag
+
+ // no need to check in glide cache if the value is available in failed
+ // cache
+ if (FavIconDownloader.isUrlAvailableInFailedCache(query) != 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(query)
}
}
- SummaryStatisticsType.MOST_CONTACTED_COUNTRIES -> {
- uiCtx {
- itemBinding.ssIcon.visibility = View.GONE
- itemBinding.ssFlag.visibility = View.VISIBLE
- itemBinding.ssFlag.text = appConnection.flag
- }
+ else -> {
+ // blocked domains, ips, countries: text-only flag
+ itemBinding.ssIcon.visibility = View.GONE
+ itemBinding.ssFlag.visibility = View.VISIBLE
+ itemBinding.ssFlag.text = appConnection.flag
}
}
}
- private fun setName(appConnection: AppConnection) {
+ private fun setName(appConnection: AppConnection, seq: Long) {
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)
- uiCtx {
- val appName = getAppName(appConnection, appInfo)
- itemBinding.ssName.visibility = View.VISIBLE
- itemBinding.ssName.text = appName
- }
- }
- }
+ SummaryStatisticsType.TOP_ACTIVE_CONNS,
+ SummaryStatisticsType.MOST_CONNECTED_APPS,
SummaryStatisticsType.MOST_BLOCKED_APPS -> {
- io {
- val appInfo = FirewallManager.getAppInfoByUid(appConnection.uid)
- uiCtx {
- val appName = getAppName(appConnection, appInfo)
- itemBinding.ssDataUsage.visibility = View.VISIBLE
- itemBinding.ssDataUsage.text = appName
- }
+ val uid = appConnection.uid
+ if (appNameByUid.containsKey(uid)) {
+ applyAppName(appConnection, appNameByUid[uid])
}
+ // else: resolveAppIdentity() re-applies once resolved
}
- SummaryStatisticsType.MOST_CONNECTED_ASN -> {
- itemBinding.ssDataUsage.visibility = View.VISIBLE
- itemBinding.ssDataUsage.text = appConnection.appOrDnsName
- }
+ SummaryStatisticsType.MOST_CONNECTED_ASN,
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_CONTACTED_DOMAINS,
SummaryStatisticsType.MOST_BLOCKED_DOMAINS -> {
itemBinding.ssContainer.visibility = View.VISIBLE
itemBinding.ssDataUsage.visibility = View.VISIBLE
@@ -374,10 +357,7 @@ class SummaryStatisticsAdapter(
itemBinding.ssDataUsage.text =
appConnection.appOrDnsName?.dropLastWhile { it == '.' }
}
- SummaryStatisticsType.MOST_CONTACTED_IPS -> {
- itemBinding.ssDataUsage.visibility = View.VISIBLE
- itemBinding.ssDataUsage.text = appConnection.ipAddress
- }
+ SummaryStatisticsType.MOST_CONTACTED_IPS,
SummaryStatisticsType.MOST_BLOCKED_IPS -> {
itemBinding.ssDataUsage.visibility = View.VISIBLE
itemBinding.ssDataUsage.text = appConnection.ipAddress
@@ -386,7 +366,7 @@ class SummaryStatisticsAdapter(
itemBinding.ssDataUsage.visibility = View.VISIBLE
val flag = getCountryNameFromFlag(appConnection.flag)
if (flag.isNotEmpty() && flag != "--") {
- itemBinding.ssDataUsage.text = getCountryNameFromFlag(appConnection.flag)
+ itemBinding.ssDataUsage.text = flag
} else {
itemBinding.ssDataUsage.text = context.getString(
R.string.two_argument_space,
@@ -398,29 +378,29 @@ class SummaryStatisticsAdapter(
}
}
- private fun getAppName(appConnection: AppConnection, appInfo: AppInfo?): String? {
- return if (appConnection.appOrDnsName.isNullOrEmpty()) {
- if (appInfo?.appName.isNullOrEmpty()) {
- context.getString(R.string.network_log_app_name_unnamed, "($appConnection.uid)")
- } else {
- appInfo?.appName ?: context.getString(R.string.network_log_app_name_unnamed, "(${appConnection.uid})")
- }
- } else {
+ private fun applyAppName(appConnection: AppConnection, cachedAppName: String?) {
+ val name = if (!cachedAppName.isNullOrEmpty()) {
+ cachedAppName
+ } else if (!appConnection.appOrDnsName.isNullOrEmpty()) {
appConnection.appOrDnsName
+ } else {
+ context.getString(
+ R.string.network_log_app_name_unnamed,
+ appConnection.uid.toString()
+ )
+ }
+ if (type == SummaryStatisticsType.MOST_CONNECTED_APPS) {
+ itemBinding.ssName.visibility = View.VISIBLE
+ itemBinding.ssName.text = name
+ } else {
+ itemBinding.ssDataUsage.visibility = View.VISIBLE
+ itemBinding.ssDataUsage.text = name
}
}
private fun setProgress(appConnection: AppConnection) {
- val c =
- if (type == SummaryStatisticsType.MOST_CONNECTED_APPS) {
- val d = appConnection.downloadBytes ?: 0L
- val u = appConnection.uploadBytes ?: 0L
- (d + u).toDouble()
- } else {
- appConnection.count.toDouble()
- }
val isBlocked = appConnection.blocked
- val percentage = calculatePercentage(c)
+ val percentage = calculatePercentage(appConnection)
if (isBlocked) {
itemBinding.ssProgress.setIndicatorColor(
fetchToggleBtnColors(context, R.color.accentBad)
@@ -437,15 +417,6 @@ class SummaryStatisticsAdapter(
}
}
- private fun loadAppIcon(drawable: Drawable?) {
- ui {
- Glide.with(context)
- .load(drawable)
- .error(Utilities.getDefaultIcon(context))
- .into(itemBinding.ssIcon)
- }
- }
-
private fun setupClickListeners(appConnection: AppConnection) {
itemBinding.ssContainer.setOnClickListener {
when (type) {
@@ -735,11 +706,15 @@ class SummaryStatisticsAdapter(
(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() }
+ val owner = context as? LifecycleOwner ?: return
+
+ withContext(Dispatchers.Main.immediate) {
+ if (!owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
+ return@withContext
+ }
+
+ f()
+ }
}
}
diff --git a/app/src/main/java/com/celzero/bravedns/adapter/WgConfigAdapter.kt b/app/src/main/java/com/celzero/bravedns/adapter/WgConfigAdapter.kt
index 536874add6..cf304068fd 100644
--- a/app/src/main/java/com/celzero/bravedns/adapter/WgConfigAdapter.kt
+++ b/app/src/main/java/com/celzero/bravedns/adapter/WgConfigAdapter.kt
@@ -25,6 +25,7 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
+import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.lifecycleScope
@@ -257,7 +258,12 @@ class WgConfigAdapter(private val context: Context, private val listener: DnsSta
if (ip.isNullOrBlank()) {
val c = WireguardManager.getConfigById(configId)
- val host = c?.getPeers()?.getOrNull(0)?.getEndpoint()?.orElse(null)?.host
+ val host =
+ c?.getPeers()
+ ?.getOrNull(0)
+ ?.getEndpoint()
+ ?.orElse(null)
+ ?.let { stripPort(it) }
if (!host.isNullOrBlank() && HostName(host).asAddress() != null) {
ip = host
}
@@ -744,7 +750,15 @@ class WgConfigAdapter(private val context: Context, private val listener: DnsSta
}
private suspend fun uiCtx(f: suspend () -> Unit) {
- withContext(Dispatchers.Main) { f() }
+ val owner = lifecycleOwner ?: (context as? LifecycleOwner) ?: return
+
+ withContext(Dispatchers.Main.immediate) {
+ if (!owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
+ return@withContext
+ }
+
+ f()
+ }
}
private suspend fun ioCtx(f: suspend () -> T): T {
diff --git a/app/src/main/java/com/celzero/bravedns/adapter/WgHopAdapter.kt b/app/src/main/java/com/celzero/bravedns/adapter/WgHopAdapter.kt
index a3d8af4154..bd8195b391 100644
--- a/app/src/main/java/com/celzero/bravedns/adapter/WgHopAdapter.kt
+++ b/app/src/main/java/com/celzero/bravedns/adapter/WgHopAdapter.kt
@@ -22,6 +22,8 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
@@ -60,6 +62,17 @@ class WgHopAdapter(
}
private var isAttached = false
+ private var recyclerView: RecyclerView? = null
+
+ // handleHop() runs in lifecycleScope (canceled only at DESTROYED). if it completes
+ // while the host is stopped (below STARTED), uiCtx skips the terminal update that
+ // dismisses the progress indicator and re-enables the checkbox, leaving the row
+ // stuck. restore the row state when the host becomes active again.
+ private val restoreStateObserver = object : DefaultLifecycleObserver {
+ override fun onStart(owner: LifecycleOwner) {
+ restoreRowStates()
+ }
+ }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HopViewHolder {
val itemBinding =
@@ -81,18 +94,36 @@ class WgHopAdapter(
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
super.onAttachedToRecyclerView(recyclerView)
+ this.recyclerView = recyclerView
isAttached = true
+ (context as? LifecycleOwner)?.lifecycle?.addObserver(restoreStateObserver)
}
override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
super.onDetachedFromRecyclerView(recyclerView)
+ (context as? LifecycleOwner)?.lifecycle?.removeObserver(restoreStateObserver)
isAttached = false
+ this.recyclerView = null
+ }
+
+ private fun restoreRowStates() {
+ val rv = recyclerView ?: return
+ for (i in 0 until rv.childCount) {
+ val child = rv.getChildAt(i)
+ val holder = rv.getChildViewHolder(child) as? HopViewHolder ?: continue
+ holder.restoreRowState()
+ }
}
inner class HopViewHolder(private val b: ListItemWgHopBinding) :
RecyclerView.ViewHolder(b.root) {
+ // last-bound config, used to restore row state if a hop completes while
+ // the host is stopped (see restoreStateObserver)
+ private var boundConfig: Config? = null
+
fun update(config: Config) {
+ boundConfig = config
val mapping = WireguardManager.getConfigFilesById(config.getId()) ?: return
b.wgHopListNameTv.text = config.getName() + " (" + config.getId() + ")"
b.wgHopListCheckbox.isChecked = config.getId() == selectedId
@@ -380,6 +411,19 @@ class WgHopAdapter(
b.wgHopListCard.isEnabled = false
}
+ // Called on the main thread when the host becomes STARTED again. Re-syncs the
+ // row with the adapter state in case handleHop() finished while stopped and
+ // its terminal uiCtx block was skipped.
+ fun restoreRowState() {
+ val config = boundConfig ?: return
+ dismissProgressIndicator()
+ b.wgHopListCard.isEnabled = true
+ val isSelected = config.getId() == selectedId
+ b.wgHopListCheckbox.isChecked = isSelected
+ val isActive = WireguardManager.getConfigFilesById(config.getId())?.isActive == true
+ setCardStroke(isSelected, isActive)
+ }
+
fun dismissProgressIndicator() {
if (!isAttached) return
@@ -403,7 +447,15 @@ class WgHopAdapter(
}
private suspend fun uiCtx(f: suspend () -> Unit) {
- withContext(Dispatchers.Main) { f() }
+ val owner = context as? LifecycleOwner ?: return
+
+ withContext(Dispatchers.Main.immediate) {
+ if (!owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
+ return@withContext
+ }
+
+ f()
+ }
}
private fun io(f: suspend () -> Unit) {
diff --git a/app/src/main/java/com/celzero/bravedns/adapter/WgIncludeAppsAdapter.kt b/app/src/main/java/com/celzero/bravedns/adapter/WgIncludeAppsAdapter.kt
index 5238b44c07..fde4aeb27b 100644
--- a/app/src/main/java/com/celzero/bravedns/adapter/WgIncludeAppsAdapter.kt
+++ b/app/src/main/java/com/celzero/bravedns/adapter/WgIncludeAppsAdapter.kt
@@ -26,6 +26,7 @@ import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
+import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.paging.PagingDataAdapter
@@ -51,7 +52,8 @@ import kotlinx.coroutines.withContext
class WgIncludeAppsAdapter(
private val context: Context,
private val proxyId: String,
- private val proxyName: String
+ private val proxyName: String,
+ private val onAppModified: (() -> Unit)? = null
) :
PagingDataAdapter(
DIFF_CALLBACK
@@ -90,7 +92,7 @@ class WgIncludeAppsAdapter(
override fun onBindViewHolder(holder: IncludedAppInfoViewHolder, position: Int) {
// Guard against stale positions during layout pass after data change
- if (position < 0 || position >= itemCount) {
+ if (position !in 0..
@@ -311,7 +325,15 @@ class WgIncludeAppsAdapter(
}
private suspend fun uiCtx(f: suspend () -> Unit) {
- withContext(Dispatchers.Main) { f() }
+ val owner = context as? LifecycleOwner ?: return
+
+ withContext(Dispatchers.Main.immediate) {
+ if (!owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
+ return@withContext
+ }
+
+ f()
+ }
}
diff --git a/app/src/main/java/com/celzero/bravedns/adapter/WgNwStatsAdapter.kt b/app/src/main/java/com/celzero/bravedns/adapter/WgNwStatsAdapter.kt
index 7685e34fa2..a5687a1822 100644
--- a/app/src/main/java/com/celzero/bravedns/adapter/WgNwStatsAdapter.kt
+++ b/app/src/main/java/com/celzero/bravedns/adapter/WgNwStatsAdapter.kt
@@ -22,6 +22,7 @@ import android.graphics.drawable.Drawable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.paging.PagingDataAdapter
@@ -201,6 +202,14 @@ class WgNwStatsAdapter(private val context: Context) :
}
private suspend fun uiCtx(f: suspend () -> Unit) {
- withContext(Dispatchers.Main) { f() }
+ val owner = context as? LifecycleOwner ?: return
+
+ withContext(Dispatchers.Main.immediate) {
+ if (!owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
+ return@withContext
+ }
+
+ f()
+ }
}
}
diff --git a/app/src/main/java/com/celzero/bravedns/adapter/WgPeersAdapter.kt b/app/src/main/java/com/celzero/bravedns/adapter/WgPeersAdapter.kt
index 98ccfe679f..d6b241eb64 100644
--- a/app/src/main/java/com/celzero/bravedns/adapter/WgPeersAdapter.kt
+++ b/app/src/main/java/com/celzero/bravedns/adapter/WgPeersAdapter.kt
@@ -20,6 +20,7 @@ import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
@@ -62,7 +63,7 @@ class WgPeersAdapter(
fun update(wgPeer: Peer) {
if (wgPeer.getEndpoint().isPresent) {
- b.endpointText.text = wgPeer.getEndpoint().get().toString()
+ b.endpointText.text = wgPeer.getEndpoint().get()
} else {
b.endpointText.visibility = View.GONE
b.endpointLabel.visibility = View.GONE
@@ -139,7 +140,15 @@ class WgPeersAdapter(
}
private suspend fun uiCtx(f: suspend () -> Unit) {
- withContext(Dispatchers.Main) { f() }
+ val owner = context as? LifecycleOwner ?: return
+
+ withContext(Dispatchers.Main.immediate) {
+ if (!owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
+ return@withContext
+ }
+
+ f()
+ }
}
private fun io(f: suspend () -> Unit) {
diff --git a/app/src/main/java/com/celzero/bravedns/backup/RestoreAgent.kt b/app/src/main/java/com/celzero/bravedns/backup/RestoreAgent.kt
index c900e1f506..62ee246f9a 100644
--- a/app/src/main/java/com/celzero/bravedns/backup/RestoreAgent.kt
+++ b/app/src/main/java/com/celzero/bravedns/backup/RestoreAgent.kt
@@ -39,11 +39,10 @@ import com.celzero.bravedns.data.AppConfig
import com.celzero.bravedns.database.AppDatabase
import com.celzero.bravedns.database.LogDatabase
import com.celzero.bravedns.service.PersistentState
-import com.celzero.bravedns.service.RethinkBlocklistManager
import com.celzero.bravedns.util.Constants
-import com.celzero.bravedns.util.RemoteFileTagUtil
import com.celzero.bravedns.util.Utilities
import com.celzero.bravedns.util.Utilities.deleteRecursive
+import kotlinx.coroutines.delay
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.File
@@ -62,9 +61,43 @@ class RestoreAgent(val context: Context, workerParams: WorkerParameters) :
companion object {
const val TAG = "RestoreAgent"
+
+ // sidecar files sqlite may place next to the main database file: WAL + shared
+ // memory (WAL mode) and the rollback journal (TRUNCATE/PERSIST journal modes
+ // seen on low-RAM devices or some OEM builds)
+ private val DB_SIDECAR_SUFFIXES = listOf("-wal", "-shm", "-journal")
+
+ // vpn stop is fire-and-forget (signalStopService); the service keeps flushing
+ // connection summaries to the log database during async teardown. retries give
+ // that teardown time to finish before the database files are replaced.
+ private const val CLOSE_ATTEMPTS = 5
+ private const val CLOSE_RETRY_DELAY_MS = 500L
+
+ /**
+ * Clears SubscriptionStatus and SubscriptionStateHistory tables after a restore.
+ */
+ suspend fun clearSubscriptionEntries(appDb: AppDatabase) {
+ try {
+ appDb.subscriptionStatusDao().deleteAll()
+ appDb.subscriptionStateHistoryDao().deleteAll()
+ Logger.i(
+ LOG_TAG_BACKUP_RESTORE,
+ "cleared subscription status and history entries during restore"
+ )
+ } catch (e: Exception) {
+ // non-fatal: reconcileWithPlayBilling() will expire orphaned rows on the
+ // next Play snapshot even if this cleanup fails
+ Logger.crash(
+ LOG_TAG_BACKUP_RESTORE,
+ "err while clearing subscription entries during restore, reason? ${e.message}",
+ e
+ )
+ }
+ }
}
override suspend fun doWork(): Result {
+ Logger.i(LOG_TAG_BACKUP_RESTORE, "restore worker started, workId? $id, isStopped? $isStopped")
val restoreUri = inputData.getString(DATA_BUILDER_RESTORE_URI)?.toUri()
if (restoreUri == null) {
Logger.w(LOG_TAG_BACKUP_RESTORE, "restore uri is null, return failure")
@@ -146,8 +179,17 @@ class RestoreAgent(val context: Context, workerParams: WorkerParameters) :
// open log database if its not open
handleDatabaseInit()
- // copy the blocklist file from assets to the remote blocklist folder
- moveRemoteBlocklistFileFromAsset()
+ // NOTE: do NOT touch any DAO beyond this point in this process.
+ // RoomDatabase.close() (called above to swap the files) permanently cancels
+ // Room's internal transaction SupervisorJob and invalidates the pooled
+ // connection of the Koin-singleton instance; a reopen restores the file
+ // level (migrations, isOpen) but not those. Any DAO call here fails with
+ // JobCancellationException / "Error code: 21, connection is closed" and
+ // every later retry in this process fails the same way.
+ // Post-restore DB work (subscription cleanup, blocklist tag seeding, wg
+ // configs) therefore runs after the caller restarts the app, in
+ // HomeScreenActivity's INTENT_RESTART_APP branch ->
+ // RefreshDatabase.ACTION_REFRESH_RESTORE.
// update app version after the restore process
updateLatestVersion()
@@ -167,37 +209,18 @@ class RestoreAgent(val context: Context, workerParams: WorkerParameters) :
"exception during restore process, reason? ${e.message}",
e
)
+ // a failure between closeDatabases() and handleDatabaseInit() (e.g. a failed
+ // migration during reopen) leaves both Koin-singleton databases closed; the
+ // next restore attempt would then fail at checkPoint() with
+ // "Error code: 21, connection is closed". best-effort reopen so the app and
+ // any retry start from open, consistent databases.
+ reopenDatabases()
return false
} finally {
inputStream?.close()
}
}
- private suspend fun moveRemoteBlocklistFileFromAsset() {
- // already there is a remote blocklist file available
- if (
- persistentState.remoteBlocklistTimestamp >
- Constants.PACKAGED_REMOTE_FILETAG_TIMESTAMP
- ) {
- try {
- RethinkBlocklistManager.readJson(
- context,
- RethinkBlocklistManager.DownloadType.REMOTE,
- persistentState.remoteBlocklistTimestamp
- )
- return
- } catch (_: Exception) {
- Logger.w(
- LOG_TAG_BACKUP_RESTORE,
- "remote blocklist file not found locally (timestamp: ${persistentState.remoteBlocklistTimestamp}), falling back to packaged asset"
- )
- // fall through to use the packaged asset version
- }
- }
-
- RemoteFileTagUtil.moveFileToLocalDir(context.applicationContext, persistentState)
- }
-
private fun handleDatabaseInit() {
// get writable database for logs
if (!logDatabase.isOpen) {
@@ -208,6 +231,7 @@ class RestoreAgent(val context: Context, workerParams: WorkerParameters) :
logDatabase.openHelper.writableDatabase
} else {
// no-op
+ Logger.vv(LOG_TAG_BACKUP_RESTORE, "log database is already open, no-op")
}
// get writable database for app
@@ -219,42 +243,90 @@ class RestoreAgent(val context: Context, workerParams: WorkerParameters) :
appDatabase.openHelper.writableDatabase
} else {
// no-op
+ Logger.vv(LOG_TAG_BACKUP_RESTORE, "app database is already open, no-op")
}
}
+
// Restore database file stored at tempDir/nameOfFileToRestore.
- private fun restoreDatabaseFile(tempDir: File): Boolean {
+ private suspend fun restoreDatabaseFile(tempDir: File): Boolean {
checkPoint()
+ // databases must be closed before their files are replaced on disk. Copying over
+ // an open database leaves the live sqlite connection serving pages of the old
+ // file: Room never re-checks the version/identity hash on an already-open db,
+ // so a backup made by an older app version (smaller schema) is served as-is and
+ // the first "select *" fails with "column does not exist" (no migration runs).
+ // close is verified: if any connection survives the retries (e.g. a long
+ // transaction in flight during vpn teardown), abort instead of copying over a
+ // live database.
+ if (!closeDatabases()) {
+ Logger.w(
+ LOG_TAG_BACKUP_RESTORE,
+ "databases still open after retries; aborting database restore"
+ )
+ return false
+ }
+
Logger.d(LOG_TAG_BACKUP_RESTORE, "begin restore database to temp dir: ${tempDir.path}")
val files = tempDir.listFiles()
if (files == null) {
Logger.w(LOG_TAG_BACKUP_RESTORE, "files to restore is empty, path: ${tempDir.path}")
+ reopenDatabases()
return false
}
- Logger.d(
- LOG_TAG_BACKUP_RESTORE,
- "List of files in backup folder: ${files.size}, path: ${tempDir.path}"
- )
- for (file in files) {
- val currentDbFile = File(context.getDatabasePath(file.name).path)
- if (
- !file.name.contains(AppDatabase.DATABASE_NAME) &&
- !file.name.contains(LogDatabase.LOGS_DATABASE_NAME)
- ) {
+ val mainDbNames = listOf(AppDatabase.DATABASE_NAME, LogDatabase.LOGS_DATABASE_NAME)
+
+ // validate the backup's main database files BEFORE touching the current ones.
+ // users restore backups created long ago (and by uninstalled installs), so a
+ // corrupt/truncated/0-byte file in the zip must not destroy the working
+ // database; abort instead and let the caller surface the failure.
+ files.filter { it.name in mainDbNames }.forEach { backupMain ->
+ if (!AppDatabase.isValidSQLiteFile(backupMain)) {
Logger.w(
LOG_TAG_BACKUP_RESTORE,
- "restore process, file name is not db, file name: ${file.name}"
+ "backup db file is not a valid sqlite file: ${backupMain.name}, " +
+ "size: ${backupMain.length()}; aborting database restore"
)
- continue
+ reopenDatabases()
+ return false
}
+ }
+
+ // remove stale sidecar files of the current databases so they cannot be
+ // recovered onto the restored main file. Sidecar handling is tolerant of
+ // every on-disk state: WAL (normal), TRUNCATE journal (low-RAM devices,
+ // "-journal" suffix), PERSIST journal (some OEMs), or no sidecars at all.
+ deleteDatabaseSidecarFiles()
+
+ val mainFiles = files.filter { it.name in mainDbNames }
+ // a sidecar from the backup is only restored together with its own main file,
+ // so a stray -wal/-shm in an old backup can never be recovered onto a main
+ // file it does not belong to (SQLite checksums would discard it anyway, but
+ // do not rely on that for data from an unknown device)
+ val sidecarFiles = files.filter { file ->
+ file.name != AppDatabase.DATABASE_NAME &&
+ file.name != LogDatabase.LOGS_DATABASE_NAME &&
+ mainDbNames.any { name -> file.name.startsWith(name) } &&
+ DB_SIDECAR_SUFFIXES.any { file.name.endsWith(it) }
+ }
+
+ Logger.d(
+ LOG_TAG_BACKUP_RESTORE,
+ "restore db files, main: ${mainFiles.map { it.name }}, " +
+ "sidecars: ${sidecarFiles.map { it.name }}"
+ )
+
+ for (file in mainFiles + sidecarFiles) {
+ val currentDbFile = File(context.getDatabasePath(file.name).path)
if (!Utilities.copy(file.path, currentDbFile.path)) {
Logger.w(
LOG_TAG_BACKUP_RESTORE,
"restore process, failure copying database file: ${file.path} to ${currentDbFile.path}"
)
+ reopenDatabases()
return false
}
Logger.i(
@@ -263,9 +335,91 @@ class RestoreAgent(val context: Context, workerParams: WorkerParameters) :
)
}
+ // close anything that may have auto-reopened on the old file during the copy
+ // window (a DAO call from the UI or a background worker). the subsequent open
+ // in handleDatabaseInit() must read the restored files fresh so Room runs its
+ // version check and the migrations needed to bring an older backup's schema
+ // (e.g. a CustomIp table without proxyId/proxyCC) up to the current version.
+ // this pass is best-effort: the files are already swapped, a survivor gets
+ // closed by handleDatabaseInit()'s reopen path below.
+ closeDatabasesQuietly()
+
return true
}
+ // attempts to close both databases until neither reports open. returns true only
+ // when a full close was observed on the final check; on false the caller must not
+ // assume the files are safe to replace.
+ private suspend fun closeDatabases(): Boolean {
+ for (attempt in 1..CLOSE_ATTEMPTS) {
+ closeDatabasesQuietly()
+ if (!appDatabase.isOpen && !logDatabase.isOpen) {
+ return true
+ }
+ Logger.w(
+ LOG_TAG_BACKUP_RESTORE,
+ "databases still open (attempt $attempt/$CLOSE_ATTEMPTS), retrying"
+ )
+ delay(CLOSE_RETRY_DELAY_MS)
+ }
+ return !appDatabase.isOpen && !logDatabase.isOpen
+ }
+
+ private fun closeDatabasesQuietly() {
+ // stack trace identifies exactly which code path (this worker, a concurrent
+ // restore attempt, or anything else) is closing the databases
+ val closer = Exception("closeDatabasesQuietly call site")
+ try {
+ if (appDatabase.isOpen) {
+ appDatabase.close()
+ Logger.w(LOG_TAG_BACKUP_RESTORE, "app database closed before restore", closer)
+ }
+ } catch (e: Exception) {
+ Logger.w(LOG_TAG_BACKUP_RESTORE, "err closing app database before restore", e)
+ }
+ try {
+ if (logDatabase.isOpen) {
+ logDatabase.close()
+ Logger.w(LOG_TAG_BACKUP_RESTORE, "log database closed before restore", closer)
+ }
+ } catch (e: Exception) {
+ Logger.w(LOG_TAG_BACKUP_RESTORE, "err closing log database before restore", e)
+ }
+ }
+
+ // remove stale wal/shm/journal sidecars of the current databases so they cannot be
+ // recovered onto the restored main file. Sidecars shipped inside the backup (if any)
+ // are copied afterwards and form a consistent set with the restored db.
+ private fun deleteDatabaseSidecarFiles() {
+ val names = listOf(AppDatabase.DATABASE_NAME, LogDatabase.LOGS_DATABASE_NAME)
+ names.forEach { name ->
+ DB_SIDECAR_SUFFIXES.forEach { suffix ->
+ val sidecar = context.getDatabasePath(name + suffix)
+ if (sidecar.exists() && !sidecar.delete()) {
+ Logger.w(
+ LOG_TAG_BACKUP_RESTORE,
+ "failed to delete database sidecar file: ${sidecar.path}"
+ )
+ }
+ }
+ }
+ }
+
+ // reopen both databases after the files were replaced; Room will run the version
+ // check on open and execute the migrations needed to bring an older backup's
+ // schema (e.g. a CustomIp table without proxyId/proxyCC) up to the current version
+ private fun reopenDatabases() {
+ try {
+ handleDatabaseInit()
+ } catch (e: Exception) {
+ Logger.crash(
+ LOG_TAG_BACKUP_RESTORE,
+ "err reopening databases during restore, reason? ${e.message}",
+ e
+ )
+ }
+ }
+
private fun checkPoint() {
Logger.i(LOG_TAG_BACKUP_RESTORE, "database checkpoint() during restore process")
appDatabase.checkPoint()
diff --git a/app/src/main/java/com/celzero/bravedns/customdownloader/IBillingServerApi.kt b/app/src/main/java/com/celzero/bravedns/customdownloader/IBillingServerApi.kt
index 00cc4cdc52..2e5f6246bd 100644
--- a/app/src/main/java/com/celzero/bravedns/customdownloader/IBillingServerApi.kt
+++ b/app/src/main/java/com/celzero/bravedns/customdownloader/IBillingServerApi.kt
@@ -34,6 +34,7 @@ interface IBillingServerApi {
* No DB session header is needed since this endpoint is not account-specific and can be served
* by any.
*/
+ @Headers("User-Agent: ${RetrofitManager.USER_AGENT}")
@GET("/p/{appVersion}")
suspend fun getPublicKey(@Path("appVersion") appVersion: String): Response?
@@ -60,7 +61,10 @@ interface IBillingServerApi {
*
* DB routing: write; first-primary ensure to use the primary DB.
*/
- @Headers("x-rethink-db-rpn-session: first-primary")
+ @Headers(
+ "x-rethink-db-rpn-session: first-primary",
+ "User-Agent: ${RetrofitManager.USER_AGENT}"
+ )
@POST("/d/acc")
suspend fun registerCustomer(
@Header("x-rethink-app-cid") accountId: String?,
@@ -81,7 +85,10 @@ interface IBillingServerApi {
*
* DB routing: write; first-primary ensure to use the primary DB.
*/
- @Headers("x-rethink-db-rpn-session: first-primary")
+ @Headers(
+ "x-rethink-db-rpn-session: first-primary",
+ "User-Agent: ${RetrofitManager.USER_AGENT}"
+ )
@POST("/d/reg")
suspend fun registerDevice(
@Header("x-rethink-app-cid") accountId: String,
@@ -93,133 +100,161 @@ interface IBillingServerApi {
/*
* Cancel the subscription for the given account ID.
- * URL shape: /g/stop?sku=xxx&purchaseToken=xxx
+ * URL shape: /g/stop?sku=xxx
*
* Headers:
* x-rethink-app-cid:
* x-rethink-app-did:
+ * x-rethink-app-purchase-token:
*
* response: {"message":"canceled subscription","purchaseId":"..."}
*
* DB routing: write; first-primary ensure to use the primary DB.
*/
- @Headers("x-rethink-db-rpn-session: first-primary")
+ @Headers(
+ "x-rethink-db-rpn-session: first-primary",
+ "User-Agent: ${RetrofitManager.USER_AGENT}"
+ )
@POST("/g/stop")
suspend fun cancelPurchase(
@Header("x-rethink-app-cid") accountId: String,
@Header("x-rethink-app-did") deviceId: String,
@Query("sku") sku: String,
- @Query("purchaseToken") purchaseToken: String,
+ @Header("x-rethink-app-purchase-token") purchaseToken: String,
@Query("vcode") vcode: String
): Response?
/*
* Refund / revoke the subscription for the given account ID.
- * URL shape: /g/refund?sku=xxx&purchaseToken=xxx
+ * URL shape: /g/refund?sku=xxx
*
* Headers:
* x-rethink-app-cid:
* x-rethink-app-did:
+ * x-rethink-app-purchase-token:
*
* response: {"message":"canceled subscription","purchaseId":"..."}
*
* DB routing: write; first-primary ensure to use the primary DB.
*/
- @Headers("x-rethink-db-rpn-session: first-primary")
+ @Headers(
+ "x-rethink-db-rpn-session: first-primary",
+ "User-Agent: ${RetrofitManager.USER_AGENT}"
+ )
@POST("/g/refund")
suspend fun revokeSubscription(
@Header("x-rethink-app-cid") accountId: String,
@Header("x-rethink-app-did") deviceId: String,
@Query("sku") sku: String,
- @Query("purchaseToken") purchaseToken: String,
+ @Header("x-rethink-app-purchase-token") purchaseToken: String,
@Query("vcode") vcode: String
): Response?
/*
* Acknowledge a purchase. POST
- * URL shape: /g/ack?sku=xxx&purchaseToken=xxx
+ * URL shape: /g/ack?sku=xxx
*
* Headers:
* x-rethink-app-cid:
* x-rethink-app-did:
+ * x-rethink-app-purchase-token:
*
* DB routing: write; first-primary ensure to use the primary DB.
*/
- @Headers("x-rethink-db-rpn-session: first-primary")
+ @Headers(
+ "x-rethink-db-rpn-session: first-primary",
+ "User-Agent: ${RetrofitManager.USER_AGENT}"
+ )
@POST("/g/ack")
suspend fun acknowledgePurchase(
@Header("x-rethink-app-cid") accountId: String,
@Header("x-rethink-app-did") deviceId: String,
@Query("sku") sku: String,
- @Query("purchaseToken") purchaseToken: String,
+ @Header("x-rethink-app-purchase-token") purchaseToken: String,
@Query("vcode") vcode: String
): Response?
/*
* Query the entitlement status of a purchase. GET
- * URL shape: /g/ack?sku=xxx&purchaseToken=xxx
+ * URL shape: /g/ack?sku=xxx
*
* Headers:
* x-rethink-app-cid:
* x-rethink-app-did:
+ * x-rethink-app-purchase-token:
*
* DB routing: read-only; first-unconstrained allows the server to use the nearest
* replica (or primary) with no consistency constraint.
*/
- @Headers("x-rethink-db-rpn-session: first-unconstrained")
+ @Headers(
+ "x-rethink-db-rpn-session: first-unconstrained",
+ "User-Agent: ${RetrofitManager.USER_AGENT}"
+ )
@GET("/g/ack")
suspend fun queryEntitlement(
@Header("x-rethink-app-cid") accountId: String,
@Header("x-rethink-app-did") deviceId: String,
@Query("sku") sku: String,
- @Query("purchaseToken") purchaseToken: String,
+ @Header("x-rethink-app-purchase-token") purchaseToken: String,
@Query("vcode") vcode: String
): Response?
/*
* Consume an expired one-time (INAPP) purchase server-side.
- * URL shape: /g/con?sku=xxx&purchaseToken=xxx
+ * URL shape: /g/con?sku=xxx
*
* Headers:
* x-rethink-app-cid:
* x-rethink-app-did:
+ * x-rethink-app-purchase-token:
*
* response: {"message":"consumed","purchaseId":"..."}
* {"error":"already consumed",...}
*
* DB routing: write; first-primary ensure to use the primary DB.
*/
- @Headers("x-rethink-db-rpn-session: first-primary")
+ @Headers(
+ "x-rethink-db-rpn-session: first-primary",
+ "User-Agent: ${RetrofitManager.USER_AGENT}"
+ )
@POST("/g/con")
suspend fun consumePurchase(
@Header("x-rethink-app-cid") accountId: String,
@Header("x-rethink-app-did") deviceId: String,
@Query("sku") sku: String,
- @Query("purchaseToken") purchaseToken: String,
+ @Header("x-rethink-app-purchase-token") purchaseToken: String,
@Query("vcode") vcode: String
): Response?
/*
* Fetch purchase/order history from the server.
- * URL shape: /g/tx?cid=xxx&purchaseToken=xxx[&tot=n][&test][&active]
+ * URL shape: /g/tx[?tot=n][&test][&active]
*
* - tot=n (1–20): also return up to n most recent purchases for the same cid,
* ordered by mtime desc. The entry for purchaseToken is always included.
* - active: only return active purchases (if purchaseToken itself is active).
* - test: operate on test-entitlement records.
*
+ * Headers:
+ * x-rethink-app-cid:
+ * x-rethink-app-did:
+ * x-rethink-app-purchase-token:
+ *
* Response: single PlayOrder JSON object (with optional `orders` array when tot=n).
*
* DB routing: read-only; first-unconstrained allows the server to use the nearest
* replica (or primary) with no consistency constraint.
*/
- @Headers("x-rethink-db-rpn-session: first-unconstrained")
+ @Headers(
+ "x-rethink-db-rpn-session: first-unconstrained",
+ "User-Agent: ${RetrofitManager.USER_AGENT}"
+ )
@GET("/g/tx")
suspend fun getPurchaseHistory(
@Header("x-rethink-app-cid") accountId: String,
@Header("x-rethink-app-did") deviceId: String,
- @Query("purchaseToken") purchaseToken: String,
+ @Header("x-rethink-app-purchase-token") purchaseToken: String,
@Query("tot") total: Int? = null,
@Query("active") active: String? = null,
@Query("vcode") vcode: String
diff --git a/app/src/main/java/com/celzero/bravedns/customdownloader/IBillingServerApiTest.kt b/app/src/main/java/com/celzero/bravedns/customdownloader/IBillingServerApiTest.kt
index 2a8bc81cbe..2bc49d5f90 100644
--- a/app/src/main/java/com/celzero/bravedns/customdownloader/IBillingServerApiTest.kt
+++ b/app/src/main/java/com/celzero/bravedns/customdownloader/IBillingServerApiTest.kt
@@ -43,9 +43,10 @@ import retrofit2.http.Query
* primary; safe for all read (GET) endpoints.
*
* ### Identity headers
- * CID and DID are no longer passed as URL query parameters. All endpoints send:
+ * CID, DID, and purchaseToken are no longer passed as URL query parameters. All endpoints send:
* `x-rethink-app-cid: `
* `x-rethink-app-did: `
+ * `x-rethink-app-purchase-token: `
* Bootstrap endpoints (`registerCustomer`, `registerDevice`) accept nullable headers
* so Retrofit omits them for first-time registrations.
*
@@ -85,7 +86,10 @@ interface IBillingServerApiTest {
*
* DB routing: write; first-primary ensure to use the primary DB.
*/
- @Headers("x-rethink-db-rpn-test-session: first-primary")
+ @Headers(
+ "x-rethink-db-rpn-test-session: first-primary",
+ "User-Agent: ${RetrofitManager.USER_AGENT}"
+ )
@POST("/d/acc")
suspend fun registerCustomer(
@Header("x-rethink-app-cid") accountId: String?,
@@ -110,7 +114,10 @@ interface IBillingServerApiTest {
*
* DB routing: write; first-primary ensure to use the primary DB.
*/
- @Headers("x-rethink-db-rpn-test-session: first-primary")
+ @Headers(
+ "x-rethink-db-rpn-test-session: first-primary",
+ "User-Agent: ${RetrofitManager.USER_AGENT}"
+ )
@POST("/d/reg")
suspend fun registerDevice(
@Header("x-rethink-app-cid") accountId: String,
@@ -123,11 +130,12 @@ interface IBillingServerApiTest {
/*
* Cancel the subscription for the given account ID (test path).
- * URL shape: /g/stop?sku=xxx&purchaseToken=xxx&test=
+ * URL shape: /g/stop?sku=xxx&test=
*
* Headers:
* x-rethink-app-cid:
* x-rethink-app-did:
+ * x-rethink-app-purchase-token:
*
* `test` is required and must be the non-null string returned by
* [RpnProxyManager.getIsTestEntitlement] (typically "test").
@@ -135,24 +143,28 @@ interface IBillingServerApiTest {
*
* DB routing: write; first-primary ensure to use the primary DB.
*/
- @Headers("x-rethink-db-rpn-test-session: first-primary")
+ @Headers(
+ "x-rethink-db-rpn-test-session: first-primary",
+ "User-Agent: ${RetrofitManager.USER_AGENT}"
+ )
@POST("/g/stop")
suspend fun cancelSubscription(
@Header("x-rethink-app-cid") accountId: String,
@Header("x-rethink-app-did") deviceId: String,
@Query("sku") sku: String,
- @Query("purchaseToken") purchaseToken: String,
+ @Header("x-rethink-app-purchase-token") purchaseToken: String,
@Query("vcode") vcode: String,
@Query("test") test: String
): Response?
/*
* Refund / revoke the subscription for the given account ID (test path).
- * URL shape: /g/refund?sku=xxx&purchaseToken=xxx&test=
+ * URL shape: /g/refund?sku=xxx&test=
*
* Headers:
* x-rethink-app-cid:
* x-rethink-app-did:
+ * x-rethink-app-purchase-token:
*
* `test` is required and must be the non-null string returned by
* [RpnProxyManager.getIsTestEntitlement].
@@ -160,48 +172,56 @@ interface IBillingServerApiTest {
*
* DB routing: write; first-primary ensure to use the primary DB.
*/
- @Headers("x-rethink-db-rpn-test-session: first-primary")
+ @Headers(
+ "x-rethink-db-rpn-test-session: first-primary",
+ "User-Agent: ${RetrofitManager.USER_AGENT}"
+ )
@POST("/g/refund")
suspend fun revokeSubscription(
@Header("x-rethink-app-cid") accountId: String,
@Header("x-rethink-app-did") deviceId: String,
@Query("sku") sku: String,
- @Query("purchaseToken") purchaseToken: String,
+ @Header("x-rethink-app-purchase-token") purchaseToken: String,
@Query("vcode") vcode: String,
@Query("test") test: String
): Response?
/*
* Acknowledge a purchase (test path). POST
- * URL shape: /g/ack?sku=xxx&purchaseToken=xxx&test=
+ * URL shape: /g/ack?sku=xxx&test=
*
* Headers:
* x-rethink-app-cid:
* x-rethink-app-did:
+ * x-rethink-app-purchase-token:
*
* `test` is required and must be the non-null string returned by
* [RpnProxyManager.getIsTestEntitlement].
*
* DB routing: write; first-primary ensure to use the primary DB.
*/
- @Headers("x-rethink-db-rpn-test-session: first-primary")
+ @Headers(
+ "x-rethink-db-rpn-test-session: first-primary",
+ "User-Agent: ${RetrofitManager.USER_AGENT}"
+ )
@POST("/g/ack")
suspend fun acknowledgePurchase(
@Header("x-rethink-app-cid") accountId: String,
@Header("x-rethink-app-did") deviceId: String,
@Query("sku") sku: String,
- @Query("purchaseToken") purchaseToken: String,
+ @Header("x-rethink-app-purchase-token") purchaseToken: String,
@Query("vcode") vcode: String,
@Query("test") test: String
): Response?
/*
* Query entitlement for a purchase (test path). GET
- * URL shape: /g/ack?sku=xxx&purchaseToken=xxx&test=
+ * URL shape: /g/ack?sku=xxx&test=
*
* Headers:
* x-rethink-app-cid:
* x-rethink-app-did:
+ * x-rethink-app-purchase-token:
*
* `test` is required and must be the non-null string returned by
* [RpnProxyManager.getIsTestEntitlement].
@@ -209,24 +229,28 @@ interface IBillingServerApiTest {
* DB routing: read-only; first-unconstrained allows the server to use the nearest
* replica (or primary) with no consistency constraint.
*/
- @Headers("x-rethink-db-rpn-test-session: first-unconstrained")
+ @Headers(
+ "x-rethink-db-rpn-test-session: first-unconstrained",
+ "User-Agent: ${RetrofitManager.USER_AGENT}"
+ )
@GET("/g/ack")
suspend fun queryEntitlement(
@Header("x-rethink-app-cid") accountId: String,
@Header("x-rethink-app-did") deviceId: String,
@Query("sku") sku: String,
- @Query("purchaseToken") purchaseToken: String,
+ @Header("x-rethink-app-purchase-token") purchaseToken: String,
@Query("vcode") vcode: String,
@Query("test") test: String
): Response?
/*
* Consume an expired one-time (INAPP) purchase server-side (test path).
- * URL shape: /g/con?sku=xxx&purchaseToken=xxx&test=
+ * URL shape: /g/con?sku=xxx&test=
*
* Headers:
* x-rethink-app-cid:
* x-rethink-app-did:
+ * x-rethink-app-purchase-token:
*
* `test` is required and must be the non-null string returned by
* [RpnProxyManager.getIsTestEntitlement].
@@ -235,24 +259,28 @@ interface IBillingServerApiTest {
*
* DB routing: write; first-primary ensure to use the primary DB.
*/
- @Headers("x-rethink-db-rpn-test-session: first-primary")
+ @Headers(
+ "x-rethink-db-rpn-test-session: first-primary",
+ "User-Agent: ${RetrofitManager.USER_AGENT}"
+ )
@POST("/g/con")
suspend fun consumePurchase(
@Header("x-rethink-app-cid") accountId: String,
@Header("x-rethink-app-did") deviceId: String,
@Query("sku") sku: String,
- @Query("purchaseToken") purchaseToken: String,
+ @Header("x-rethink-app-purchase-token") purchaseToken: String,
@Query("vcode") vcode: String,
@Query("test") test: String
): Response?
/*
* Fetch purchase/order history from the server (test path).
- * URL shape: /g/tx?purchaseToken=xxx&test=[&tot=n][&active]
+ * URL shape: /g/tx?test=[&tot=n][&active]
*
* Headers:
* x-rethink-app-cid:
* x-rethink-app-did:
+ * x-rethink-app-purchase-token:
*
* `test` is required and must be the non-null string returned by
* [RpnProxyManager.getIsTestEntitlement].
@@ -262,12 +290,15 @@ interface IBillingServerApiTest {
* DB routing: read-only; first-unconstrained allows the server to use the nearest
* replica (or primary) with no consistency constraint.
*/
- @Headers("x-rethink-db-rpn-test-session: first-unconstrained")
+ @Headers(
+ "x-rethink-db-rpn-test-session: first-unconstrained",
+ "User-Agent: ${RetrofitManager.USER_AGENT}"
+ )
@GET("/g/tx")
suspend fun getPurchaseHistory(
@Header("x-rethink-app-cid") accountId: String,
@Header("x-rethink-app-did") deviceId: String,
- @Query("purchaseToken") purchaseToken: String,
+ @Header("x-rethink-app-purchase-token") purchaseToken: String,
@Query("tot") total: Int? = null,
@Query("active") active: String? = null,
@Query("test") test: String,
diff --git a/app/src/main/java/com/celzero/bravedns/customdownloader/LocalBlocklistCoordinator.kt b/app/src/main/java/com/celzero/bravedns/customdownloader/LocalBlocklistCoordinator.kt
index 80d6e81914..23c567faf9 100644
--- a/app/src/main/java/com/celzero/bravedns/customdownloader/LocalBlocklistCoordinator.kt
+++ b/app/src/main/java/com/celzero/bravedns/customdownloader/LocalBlocklistCoordinator.kt
@@ -28,6 +28,7 @@ import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
+import androidx.work.workDataOf
import com.celzero.bravedns.R
import com.celzero.bravedns.RethinkDnsApplication.Companion.DEBUG
import com.celzero.bravedns.customdownloader.RetrofitManager.Companion.getBlocklistBaseBuilder
@@ -103,12 +104,16 @@ class LocalBlocklistCoordinator(val context: Context, workerParams: WorkerParame
val timestamp = inputData.getLong("blocklistTimestamp", 0)
if (runAttemptCount > MAX_RETRY_COUNT) {
- Logger.w(LOG_TAG_DOWNLOAD, "Local blocklist download failed after $MAX_RETRY_COUNT attempts")
+ val msg = context.getString(R.string.download_err_internal)
+ Logger.w(LOG_TAG_DOWNLOAD, "Max retries reached: $runAttemptCount")
+ persistentState.lastDownloadFailureReason = msg
return Result.failure()
}
if (SystemClock.elapsedRealtime() - startTime > BLOCKLIST_DOWNLOAD_TIMEOUT_MS) {
- Logger.w(LOG_TAG_DOWNLOAD, "Local blocklist download timeout")
+ val msg = context.getString(R.string.download_err_network)
+ Logger.w(LOG_TAG_DOWNLOAD, "Timeout reached")
+ persistentState.lastDownloadFailureReason = msg
return Result.failure()
}
@@ -117,8 +122,9 @@ class LocalBlocklistCoordinator(val context: Context, workerParams: WorkerParame
if (isDownloadCancelled()) {
Logger.i(LOG_TAG_DOWNLOAD, "Local blocklist download cancelled")
notifyDownloadCancelled(context)
+ } else {
+ Logger.i(LOG_TAG_DOWNLOAD, "Local blocklist download failed")
}
- Logger.i(LOG_TAG_DOWNLOAD, "Local blocklist download failed")
Result.failure()
}
true -> {
@@ -136,14 +142,12 @@ class LocalBlocklistCoordinator(val context: Context, workerParams: WorkerParame
)
notifyDownloadCancelled(context)
} catch (ex: Exception) {
- Logger.e(
- LOG_TAG_DOWNLOAD,
- "Local blocklist download, received cancellation exception: ${ex.message}",
- ex
- )
+ val msg = context.getString(R.string.download_err_internal)
+ Logger.e(LOG_TAG_DOWNLOAD, msg, ex)
+ persistentState.lastDownloadFailureReason = msg
notifyDownloadFailure(context)
} finally {
- clear()
+ clear(inputData.getLong("blocklistTimestamp", 0))
}
return Result.failure()
}
@@ -153,11 +157,14 @@ class LocalBlocklistCoordinator(val context: Context, workerParams: WorkerParame
val file = makeTempDownloadDir(timestamp)
if (file == null) {
- Logger.e(LOG_TAG_DOWNLOAD, "Error creating temp folder for download")
+ val msg = context.getString(R.string.download_err_storage)
+ Logger.e(LOG_TAG_DOWNLOAD, "Error creating temp folder")
+ persistentState.lastDownloadFailureReason = msg
return false
}
- Constants.ONDEVICE_BLOCKLISTS_IN_APP.forEachIndexed { _, onDeviceBlocklistsMetadata ->
+ val totalFiles = Constants.ONDEVICE_BLOCKLISTS_IN_APP.size
+ Constants.ONDEVICE_BLOCKLISTS_IN_APP.forEachIndexed { index, onDeviceBlocklistsMetadata ->
val id = generateCustomDownloadId()
downloadStatuses[id] = DownloadStatus.RUNNING
@@ -173,25 +180,43 @@ class LocalBlocklistCoordinator(val context: Context, workerParams: WorkerParame
return false
}
- when (startFileDownload(context, onDeviceBlocklistsMetadata.url, filePath)) {
+ // Overall progress = (completed files) / total
+ val initialProgress = (index * 100 / totalFiles)
+ setProgress(workDataOf("progress" to initialProgress))
+ // keep the notification's progress bar in sync at file boundaries too, so it
+ // doesn't sit frozen at the previous file's percentage while the next file's
+ // download connection is still being established
+ updateProgress(context, initialProgress)
+
+ when (startFileDownload(context, onDeviceBlocklistsMetadata.url, filePath, index, totalFiles)) {
true -> {
Logger.i(LOG_TAG_DOWNLOAD, "Download successful for id: $id")
downloadStatuses[id] = DownloadStatus.SUCCESSFUL
}
false -> {
- Logger.e(LOG_TAG_DOWNLOAD, "Download failed for id: $id")
+ val msg = context.getString(R.string.download_err_network)
+ Logger.e(LOG_TAG_DOWNLOAD, "Download failed for ${onDeviceBlocklistsMetadata.filename}")
+ persistentState.lastDownloadFailureReason = msg
downloadStatuses[id] = DownloadStatus.FAILED
return false
}
}
}
+
+ // final progress before processing
+ setProgress(workDataOf("progress" to 100))
+ updateProgress(context, 100)
+
+ // transition to processing
+ setProgress(workDataOf("processing" to true))
+ notifyProcessing(context)
+
// check if all the files are downloaded, as of now the check if for only number of files
// downloaded. TODO: Later add checksum matching as well
if (!isDownloadComplete(file)) {
- Logger.e(
- LOG_TAG_DOWNLOAD,
- "Local blocklist validation failed for timestamp: $timestamp"
- )
+ val msg = context.getString(R.string.download_err_validation)
+ Logger.e(LOG_TAG_DOWNLOAD, "Verification failed (files missing)")
+ persistentState.lastDownloadFailureReason = msg
notifyDownloadFailure(context)
return false
}
@@ -199,7 +224,9 @@ class LocalBlocklistCoordinator(val context: Context, workerParams: WorkerParame
if (isDownloadCancelled()) return false
if (!moveLocalBlocklistFiles(context, timestamp)) {
- Logger.e(LOG_TAG_DOWNLOAD, "Issue while moving the downloaded files: $timestamp")
+ val msg = context.getString(R.string.download_err_storage)
+ Logger.e(LOG_TAG_DOWNLOAD, "Error moving downloaded files")
+ persistentState.lastDownloadFailureReason = msg
notifyDownloadFailure(context)
return false
}
@@ -207,7 +234,9 @@ class LocalBlocklistCoordinator(val context: Context, workerParams: WorkerParame
if (isDownloadCancelled()) return false
if (!isLocalBlocklistDownloadValid(context, timestamp)) {
- Logger.e(LOG_TAG_DOWNLOAD, "Invalid download for local blocklist files: $timestamp")
+ val msg = context.getString(R.string.download_err_validation)
+ Logger.e(LOG_TAG_DOWNLOAD, "Verification failed (checksum mismatch)")
+ persistentState.lastDownloadFailureReason = msg
notifyDownloadFailure(context)
return false
}
@@ -216,7 +245,9 @@ class LocalBlocklistCoordinator(val context: Context, workerParams: WorkerParame
val result = updateTagsToDb(timestamp)
if (!result) {
- Logger.e(LOG_TAG_DOWNLOAD, "Invalid download for local blocklist files: $timestamp")
+ val msg = context.getString(R.string.download_err_internal)
+ Logger.e(LOG_TAG_DOWNLOAD, "Database update failed")
+ persistentState.lastDownloadFailureReason = msg
notifyDownloadFailure(context)
return false
}
@@ -256,6 +287,8 @@ class LocalBlocklistCoordinator(val context: Context, workerParams: WorkerParame
context: Context,
urlPath: String,
filePath: String,
+ fileIndex: Int,
+ totalFiles: Int,
retryCount: Int = 0
): Boolean {
// enable the OkHttp's logging only in debug mode for testing
@@ -269,19 +302,23 @@ class LocalBlocklistCoordinator(val context: Context, workerParams: WorkerParame
Logger.i(LOG_TAG_DOWNLOAD, "Downloading file: $filePath, urlPath: $urlPath")
val response = retrofit.downloadLocalBlocklistFile(urlPath, persistentState.appVersion, "")
if (response?.isSuccessful == true) {
- return downloadFile(context, response.body(), filePath)
+ return downloadFile(context, response.body(), filePath, fileIndex, totalFiles)
} else {
Logger.e(
LOG_TAG_DOWNLOAD,
"Error in startFileDownload: ${response?.message()}, code: ${response?.code()}"
)
}
+ } catch (e: CancellationException) {
+ // never swallow cooperative cancellation; rethrow so WorkManager marks the
+ // worker CANCELLED instead of mislabelling it as a network failure
+ throw e
} catch (e: Exception) {
Logger.e(LOG_TAG_DOWNLOAD, "Error in startFileDownload: ${e.message}", e)
}
return if (isRetryRequired(retryCount)) {
Logger.i(LOG_TAG_DOWNLOAD, "retrying download($urlPath) $filePath, count: $retryCount")
- startFileDownload(context, urlPath, filePath, retryCount + 1)
+ startFileDownload(context, urlPath, filePath, fileIndex, totalFiles, retryCount + 1)
} else {
Logger.i(LOG_TAG_DOWNLOAD, "download failed for $filePath, retry: $retryCount")
false
@@ -293,7 +330,7 @@ class LocalBlocklistCoordinator(val context: Context, workerParams: WorkerParame
return retryCount < MAX_RETRY_COUNT
}
- private fun downloadFile(context: Context, body: ResponseBody?, fileName: String): Boolean {
+ private suspend fun downloadFile(context: Context, body: ResponseBody?, fileName: String, fileIndex: Int, totalFiles: Int): Boolean {
if (body == null) {
return false
}
@@ -308,7 +345,7 @@ class LocalBlocklistCoordinator(val context: Context, workerParams: WorkerParame
// file size and download percentage
var bytesRead: Int
val contentLength = body.contentLength()
- val expectedMB: Double = contentLength / BYTES_PER_MB
+ val expectedMB: Double = if (contentLength > 0) contentLength / BYTES_PER_MB else 0.0
var downloadedMB = 0.0
input = BufferedInputStream(body.byteStream(), BUFFERED_INPUT_STREAM_SIZE)
val startMs = SystemClock.elapsedRealtime()
@@ -317,11 +354,20 @@ class LocalBlocklistCoordinator(val context: Context, workerParams: WorkerParame
while (input.read(buf).also { bytesRead = it } != -1) {
val elapsedMs = SystemClock.elapsedRealtime() - startMs
downloadedMB += bytesToMB(bytesRead)
- val progress =
- if (contentLength == Long.MAX_VALUE || expectedMB == 0.0) 0
- else (downloadedMB * NOTIFICATION_PROGRESS_MAX / expectedMB).toInt()
+
+ val fileProgress =
+ if (expectedMB <= 0.0) 0
+ else (downloadedMB * 100 / expectedMB).toInt().coerceIn(0, 100)
+
+ // Overall progress = (completed files + fraction of current file) / total
+ val overallProgress = (((fileIndex.toDouble() * 100) + fileProgress) / totalFiles).toInt()
+
if (elapsedMs >= progressJumpsMs) {
- updateProgress(context, progress)
+ // notification must reflect the overall (all-files) progress, not just
+ // the current file's, otherwise the progress bar/percentage resets back
+ // towards 0% every time a new file starts downloading
+ updateProgress(context, overallProgress)
+ setProgress(workDataOf("progress" to overallProgress))
// increase the next update duration linearly by another sec; ie,
// update in the intervals of once every [1, 2, 3, 4, ...] secs
progressJumpsMs += PROGRESS_UPDATE_INTERVAL_MS
@@ -331,6 +377,8 @@ class LocalBlocklistCoordinator(val context: Context, workerParams: WorkerParame
output.flush()
Logger.i(LOG_TAG_DOWNLOAD, "$fileName > ${downloadedMB}MB downloaded")
return true
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
Logger.e(LOG_TAG_DOWNLOAD, "$fileName download err: ${e.message}", e)
} finally {
@@ -508,14 +556,34 @@ class LocalBlocklistCoordinator(val context: Context, workerParams: WorkerParame
private fun updateProgress(context: Context, progress: Int) {
val builder = getBuilder(context)
- val cur = if (progress <= 0) 0 else progress
- val max = if (cur <= 0) 0 else NOTIFICATION_PROGRESS_MAX
+ // clamp defensively: rounding in the overall-progress computation could otherwise
+ // push this a hair past 100 on the last tick of the last file
+ val cur = progress.coerceIn(0, NOTIFICATION_PROGRESS_MAX)
val forever = cur <= 0
+ val max = if (forever) 0 else NOTIFICATION_PROGRESS_MAX
+ // surface the percentage as text as well, since the progress bar alone isn't a
+ // reliable indicator of percent-complete across all devices/launchers
+ val contentText = context.getString(R.string.notif_download_progress_content, cur)
+ builder
+ .setContentText(contentText)
+ .setStyle(NotificationCompat.BigTextStyle().bigText(contentText))
builder.setProgress(max, cur, forever)
getNotificationManager(context)
.notify(DOWNLOAD_NOTIFICATION_TAG, DOWNLOAD_NOTIFICATION_ID, builder.build())
}
+ private fun notifyProcessing(context: Context) {
+ val builder = getBuilder(context)
+ val contentText = context.getString(R.string.notif_download_processing_content)
+ builder
+ .setContentText(contentText)
+ .setStyle(NotificationCompat.BigTextStyle().bigText(contentText))
+ // indeterminate spinner while files are being verified/validated
+ builder.setProgress(0, 0, true)
+ getNotificationManager(context)
+ .notify(DOWNLOAD_NOTIFICATION_TAG, DOWNLOAD_NOTIFICATION_ID, builder.build())
+ }
+
private fun notifyDownloadFailure(context: Context) {
val builder = getBuilder(context)
val contentText = context.getString(R.string.notif_download_failure_content)
@@ -564,8 +632,17 @@ class LocalBlocklistCoordinator(val context: Context, workerParams: WorkerParame
.notify(DOWNLOAD_NOTIFICATION_TAG, DOWNLOAD_NOTIFICATION_ID, builder.build())
}
- private fun clear() {
+ private fun clear(timestamp: Long) {
downloadStatuses.clear()
+ // deleteBlocklistResidue() is a no-op when localBlocklistTimestamp is still
+ // INIT_TIME_MS (ie, no download has ever succeeded). That guard would otherwise
+ // leave this attempt's own temp dir (tempDownloadBasePath, "-timestamp") behind
+ // forever on a first-ever failed download, so always delete it explicitly here.
+ if (timestamp > INIT_TIME_MS) {
+ Utilities.deleteRecursive(
+ File(tempDownloadBasePath(context, LOCAL_BLOCKLIST_DOWNLOAD_FOLDER_NAME, timestamp))
+ )
+ }
BlocklistDownloadHelper.deleteBlocklistResidue(
context,
LOCAL_BLOCKLIST_DOWNLOAD_FOLDER_NAME,
diff --git a/app/src/main/java/com/celzero/bravedns/customdownloader/RemoteBlocklistCoordinator.kt b/app/src/main/java/com/celzero/bravedns/customdownloader/RemoteBlocklistCoordinator.kt
index 545f306ad4..17d2070174 100644
--- a/app/src/main/java/com/celzero/bravedns/customdownloader/RemoteBlocklistCoordinator.kt
+++ b/app/src/main/java/com/celzero/bravedns/customdownloader/RemoteBlocklistCoordinator.kt
@@ -21,6 +21,8 @@ import android.content.Context
import android.os.SystemClock
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
+import androidx.work.workDataOf
+import com.celzero.bravedns.R
import com.celzero.bravedns.download.BlocklistDownloadHelper
import com.celzero.bravedns.service.PersistentState
import com.celzero.bravedns.service.RethinkBlocklistManager
@@ -49,17 +51,22 @@ class RemoteBlocklistCoordinator(val context: Context, workerParams: WorkerParam
override suspend fun doWork(): Result {
Logger.i(LOG_TAG_DOWNLOAD, "Remote blocklist download worker started")
+ setProgress(workDataOf("progress" to 0))
try {
val startTime = inputData.getLong("workerStartTime", 0)
val timestamp = inputData.getLong("blocklistTimestamp", 0)
if (SystemClock.elapsedRealtime() - startTime > BLOCKLIST_DOWNLOAD_TIMEOUT_MS) {
+ val msg = context.getString(R.string.download_err_network)
+ Logger.w(LOG_TAG_DOWNLOAD, "Timeout reached")
+ persistentState.lastDownloadFailureReason = msg
return Result.failure()
}
val downloadStatus = downloadRemoteBlocklist(timestamp)
// reset updatable time stamp
if (downloadStatus) {
+ setProgress(workDataOf("processing" to true))
// update the download related persistence status on download success
updatePersistenceOnCopySuccess(timestamp)
// Delete stale remote blocklist directories, keeping only the one whose
@@ -70,6 +77,7 @@ class RemoteBlocklistCoordinator(val context: Context, workerParams: WorkerParam
persistentState.remoteBlocklistTimestamp
)
} else {
+ // failure reason should be set in downloadRemoteBlocklist
// clean up the partial directory created for this failed download attempt
cleanupFailedRemoteDownload(timestamp)
@@ -101,9 +109,13 @@ class RemoteBlocklistCoordinator(val context: Context, workerParams: WorkerParam
} catch (ex: CancellationException) {
Logger.e(
LOG_TAG_DOWNLOAD,
- "Local blocklist download, received cancellation exception: ${ex.message}",
+ "Remote blocklist download, received cancellation exception: ${ex.message}",
ex
)
+ } catch (ex: Exception) {
+ val msg = context.getString(R.string.download_err_internal)
+ Logger.e(LOG_TAG_DOWNLOAD, "Remote coordinator error: ${ex.message}", ex)
+ persistentState.lastDownloadFailureReason = msg
}
return Result.failure()
}
@@ -129,16 +141,26 @@ class RemoteBlocklistCoordinator(val context: Context, workerParams: WorkerParam
)
if (response?.isSuccessful == true) {
+ setProgress(workDataOf("progress" to 100))
return saveRemoteFile(response.body(), timestamp)
+ } else {
+ val msg = context.getString(R.string.download_err_network)
+ Logger.e(LOG_TAG_DOWNLOAD, "Remote download failed: ${response?.message()} (${response?.code()})")
+ persistentState.lastDownloadFailureReason = msg
}
+ } catch (ex: CancellationException) {
+ // never swallow cooperative cancellation
+ throw ex
} catch (ex: Exception) {
- Logger.e(LOG_TAG_DOWNLOAD, "err in downloadRemoteBlocklist: ${ex.message}", ex)
+ val msg = context.getString(R.string.download_err_network)
+ Logger.e(LOG_TAG_DOWNLOAD, "Remote download exception: ${ex.message}", ex)
+ persistentState.lastDownloadFailureReason = msg
}
return if (isRetryRequired(retryCount)) {
Logger.i(LOG_TAG_DOWNLOAD, "retrying the downloadRemoteBlocklist")
downloadRemoteBlocklist(timestamp, retryCount + 1)
} else {
- Logger.i(LOG_TAG_DOWNLOAD, "retry count exceeded, returning null")
+ Logger.i(LOG_TAG_DOWNLOAD, "retry count exceeded, returning false")
false
}
}
@@ -154,13 +176,19 @@ class RemoteBlocklistCoordinator(val context: Context, workerParams: WorkerParam
filetag.writeText(jsonObject.toString())
// write the file tag json file into database
- return RethinkBlocklistManager.readJson(
+ val result = RethinkBlocklistManager.readJson(
context,
RethinkBlocklistManager.DownloadType.REMOTE,
timestamp
)
+ if (!result) {
+ persistentState.lastDownloadFailureReason = context.getString(R.string.download_err_internal)
+ }
+ return result
} catch (e: IOException) {
- Logger.w(LOG_TAG_DOWNLOAD, "could not create filetag.json at version $timestamp", e)
+ val msg = context.getString(R.string.download_err_storage)
+ Logger.w(LOG_TAG_DOWNLOAD, "IOException: could not create filetag.json", e)
+ persistentState.lastDownloadFailureReason = msg
}
return false
}
@@ -172,7 +200,11 @@ class RemoteBlocklistCoordinator(val context: Context, workerParams: WorkerParam
context,
Constants.REMOTE_BLOCKLIST_DOWNLOAD_FOLDER_NAME,
timestamp
- ) ?: return null
+ )
+ if (dir == null) {
+ persistentState.lastDownloadFailureReason = context.getString(R.string.download_err_storage)
+ return null
+ }
if (!dir.exists()) {
dir.mkdirs()
@@ -183,11 +215,9 @@ class RemoteBlocklistCoordinator(val context: Context, workerParams: WorkerParam
}
return filePath
} catch (e: IOException) {
- Logger.e(
- LOG_TAG_DOWNLOAD,
- "err creating remote blocklist, ts: $timestamp" + e.message,
- e
- )
+ val msg = context.getString(R.string.download_err_storage)
+ Logger.e(LOG_TAG_DOWNLOAD, "IOException while creating file: ${e.message}", e)
+ persistentState.lastDownloadFailureReason = msg
}
return null
}
diff --git a/app/src/main/java/com/celzero/bravedns/customdownloader/RetrofitManager.kt b/app/src/main/java/com/celzero/bravedns/customdownloader/RetrofitManager.kt
index c1a90e70e9..7e5bda00ce 100644
--- a/app/src/main/java/com/celzero/bravedns/customdownloader/RetrofitManager.kt
+++ b/app/src/main/java/com/celzero/bravedns/customdownloader/RetrofitManager.kt
@@ -17,6 +17,7 @@ package com.celzero.bravedns.customdownloader
import android.content.Context
import android.net.Uri
+import com.celzero.bravedns.BuildConfig
import com.celzero.bravedns.R
import com.celzero.bravedns.util.Logger
import com.celzero.bravedns.util.Logger.LOG_OKHTTP
@@ -34,6 +35,7 @@ import okhttp3.dnsoverhttps.DnsOverHttps
import retrofit2.Retrofit
import org.koin.core.context.GlobalContext
import java.net.InetAddress
+import java.net.Proxy
import java.net.UnknownHostException
import java.util.concurrent.TimeUnit
import kotlin.enums.enumEntries
@@ -64,6 +66,12 @@ class RetrofitManager {
// log writes never block OkHttp's network threads.
private val logScope = CoroutineScope(Daemons.make("RayIdLogger"))
+ /**
+ * Compile-time constant User-Agent identifying app-originated requests.
+ * Usable directly inside Retrofit @Headers annotations
+ */
+ const val USER_AGENT: String = "rethink-app/${BuildConfig.VERSION_NAME}"
+
/** Captures Cloudflare's cf-ray header for request tracing. */
val rayIdInterceptor = Interceptor { chain ->
val request = chain.request()
@@ -120,6 +128,12 @@ class RetrofitManager {
b.readTimeout(READ_TIMEOUT_MINUTES, TimeUnit.MINUTES)
b.writeTimeout(WRITE_TIMEOUT_MINUTES, TimeUnit.MINUTES)
b.retryOnConnectionFailure(true)
+ // Never consult ProxySelector.getDefault(): when a device-wide HTTP proxy is set
+ // without a port (Settings.Global.HTTP_PROXY), the platform populates
+ // http(s).proxyPort with -1 and DefaultProxySelector.select() throws
+ // IllegalArgumentException("port out of range:-1") inside OkHttp's RouteSelector.
+ // Pinning NO_PROXY bypasses the selector entirely (see RethinkGlideModule).
+ b.proxy(Proxy.NO_PROXY)
// Always active: captures cf-ray header; never logs
// request headers (cid / did / sessionToken stay out of logs).
b.addInterceptor(rayIdInterceptor)
diff --git a/app/src/main/java/com/celzero/bravedns/data/AppConfig.kt b/app/src/main/java/com/celzero/bravedns/data/AppConfig.kt
index 29724e0432..354e4f27d4 100644
--- a/app/src/main/java/com/celzero/bravedns/data/AppConfig.kt
+++ b/app/src/main/java/com/celzero/bravedns/data/AppConfig.kt
@@ -19,6 +19,7 @@ import com.celzero.bravedns.util.Logger
import com.celzero.bravedns.util.Logger.LOG_TAG_VPN
import android.content.Context
import androidx.lifecycle.LiveData
+import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import com.celzero.bravedns.R
import com.celzero.bravedns.database.ConnectionTrackerRepository
@@ -41,7 +42,10 @@ import com.celzero.bravedns.database.ProxyEndpoint
import com.celzero.bravedns.database.ProxyEndpointRepository
import com.celzero.bravedns.database.RethinkDnsEndpoint
import com.celzero.bravedns.database.RethinkDnsEndpointRepository
+import com.celzero.bravedns.database.RethinkLogRepository
import com.celzero.bravedns.database.Severity
+import com.celzero.bravedns.database.SmartDnsEndpoint
+import com.celzero.bravedns.database.SmartDnsEndpointRepository
import com.celzero.bravedns.service.EventLogger
import com.celzero.bravedns.service.PersistentState
import com.celzero.bravedns.service.TcpProxyHelper
@@ -66,10 +70,12 @@ internal constructor(
private val dnsCryptRelayEndpointRepository: DnsCryptRelayEndpointRepository,
private val doTEndpointRepository: DoTEndpointRepository,
private val oDoHEndpointRepository: ODoHEndpointRepository,
+ private val smartDnsEndpointRepository: SmartDnsEndpointRepository,
private val proxyEndpointRepository: ProxyEndpointRepository,
private val persistentState: PersistentState,
private val networkLogs: ConnectionTrackerRepository,
private val dnsLogs: DnsLogRepository,
+ private val rethinkLogs: RethinkLogRepository,
private val eventLogger: EventLogger
) {
private val braveModeObserver: MutableLiveData = MutableLiveData()
@@ -582,7 +588,10 @@ internal constructor(
postConnectedDnsName(context.getString(R.string.network_dns))
}
DnsType.SMART_DNS -> {
- postConnectedDnsName(context.getString(R.string.smart_dns))
+ val endpointName = getSelectedSmartDnsEndpoint()?.dnsName
+ ?: ""
+ val name = context.getString(R.string.two_argument_space, context.getString(R.string.smart_dns), endpointName)
+ postConnectedDnsName(name)
}
}
}
@@ -896,20 +905,26 @@ internal constructor(
)
}
- suspend fun enableSmartDns() {
+ suspend fun enableSmartDns(id: Int) {
if (getDnsType() != DnsType.SMART_DNS) {
removeConnectionStatus()
}
val prev = persistentState.connectedDnsName
+ // persist the user's smart dns selection before triggering the dns change
+ smartDnsEndpointRepository.select(id)
onDnsChange(DnsType.SMART_DNS)
logEvent(
EventType.DNS_SERVER_CHANGE,
"Smart DNS enabled",
- "Smart DNS enabled, prev: $prev"
+ "Smart DNS enabled, id: $id, prev: $prev"
)
}
+ suspend fun getSelectedSmartDnsEndpoint(): SmartDnsEndpoint? {
+ return smartDnsEndpointRepository.getSelectedEndpoint()
+ }
+
fun isSystemDns(): Boolean {
return getDnsType().isSystemDns()
}
@@ -951,7 +966,7 @@ internal constructor(
// no-op, no need to remove connection status
}
DnsType.SMART_DNS -> {
- // no-op, no need to remove connection status
+ smartDnsEndpointRepository.removeConnectionStatus()
}
}
}
@@ -1301,7 +1316,25 @@ internal constructor(
return a
}
- val networkLogsCount: LiveData = networkLogs.logsCount()
+ // total connections available in the database. Network logs are split
+ // across two tables with disjoint uid ranges (RethinkLog holds Rethink's
+ // own traffic), so the true count is the sum of both; each source emits
+ // on its table's invalidation and the sum re-publishes on either change.
+ val networkLogsCount: LiveData = MediatorLiveData().apply {
+ var connCount = 0L
+ var rethinkCount = 0L
+ fun recompute() {
+ value = connCount + rethinkCount
+ }
+ addSource(networkLogs.logsCount()) {
+ connCount = it
+ recompute()
+ }
+ addSource(rethinkLogs.logsCount()) {
+ rethinkCount = it
+ recompute()
+ }
+ }
val dnsLogsCount: LiveData = dnsLogs.logsCount()
diff --git a/app/src/main/java/com/celzero/bravedns/data/DataModule.kt b/app/src/main/java/com/celzero/bravedns/data/DataModule.kt
index 6a7ad03fe2..9069bfa089 100644
--- a/app/src/main/java/com/celzero/bravedns/data/DataModule.kt
+++ b/app/src/main/java/com/celzero/bravedns/data/DataModule.kt
@@ -34,6 +34,8 @@ object DataModule {
get(),
get(),
get(),
+ get(),
+ get(),
get()
)
}
diff --git a/app/src/main/java/com/celzero/bravedns/data/RpnConnStatsSummary.kt b/app/src/main/java/com/celzero/bravedns/data/RpnConnStatsSummary.kt
new file mode 100644
index 0000000000..79d9286632
--- /dev/null
+++ b/app/src/main/java/com/celzero/bravedns/data/RpnConnStatsSummary.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2026 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.data
+
+/**
+ * Aggregate connection stats for traffic routed through a proxy (matched by
+ * [ConnectionTracker.proxyDetails]) within a time window. Consumed by the
+ * RPN stats bottom sheet.
+ */
+data class RpnConnStatsSummary(
+ val connectionsCount: Int,
+ val totalDownload: Long,
+ val totalUpload: Long,
+ val blockedCount: Int,
+ val appCount: Int
+)
diff --git a/app/src/main/java/com/celzero/bravedns/database/ActivityBucketRow.kt b/app/src/main/java/com/celzero/bravedns/database/ActivityBucketRow.kt
new file mode 100644
index 0000000000..a10e3b8718
--- /dev/null
+++ b/app/src/main/java/com/celzero/bravedns/database/ActivityBucketRow.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2026 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.database
+
+/**
+ * Aggregated count of log rows belonging to one time bucket of the activity
+ * wall. Used by LogActivityAggregator to rebuild the wall from the log
+ * databases without loading individual rows. [bucketIndex] is
+ * (timestamp - rangeStart) / bucketMs; [blocked] mirrors the row's isBlocked
+ * column (1/0); [total] is the number of rows in that bucket/class pair.
+ */
+data class ActivityBucketRow(
+ val bucketIndex: Long,
+ val blocked: Int,
+ val total: Long
+)
+
+/**
+ * Blocked/total counts of log rows within an arbitrary time window.
+ */
+data class WindowCountRow(
+ val blocked: Long,
+ val total: Long
+)
+
+/**
+ * Per-app activity counts within a time window, grouped by (uid, appName)
+ * across the dns/connection log tables. [blocked] is the number of rows with
+ * isBlocked = 1; allowed = total - blocked.
+ */
+data class AppActivityRow(
+ val uid: Int,
+ val appName: String,
+ val total: Long,
+ val blocked: Long
+)
diff --git a/app/src/main/java/com/celzero/bravedns/database/AppDatabase.kt b/app/src/main/java/com/celzero/bravedns/database/AppDatabase.kt
index eaf7b1b568..4a2f73c14a 100644
--- a/app/src/main/java/com/celzero/bravedns/database/AppDatabase.kt
+++ b/app/src/main/java/com/celzero/bravedns/database/AppDatabase.kt
@@ -55,9 +55,10 @@ import com.celzero.bravedns.util.Constants
SubscriptionStatus::class,
SubscriptionStateHistory::class,
CountryConfig::class,
- SponsorEntity::class
+ SponsorEntity::class,
+ SmartDnsEndpoint::class
],
- version = 32,
+ version = 35,
exportSchema = false
)
@TypeConverters(Converters::class)
@@ -73,6 +74,12 @@ abstract class AppDatabase : RoomDatabase() {
// "Bad database header" failure seen after clearing app storage.
private const val DATABASE_PATH = "database/rethink_v31.db"
private const val PRAGMA = "pragma wal_checkpoint(full)"
+ // cap the WAL file size (32MB). Without this, the -wal file stays at its
+ // high-water mark forever: SQLite reuses WAL space after a checkpoint but
+ // never shrinks the file, so a single large transaction (e.g. a bulk log
+ // purge) can leave a multi-hundred-MB -wal on disk for the lifetime of the
+ // installation.
+ private const val JOURNAL_SIZE_LIMIT_BYTES = 32 * 1024 * 1024
private const val APP_NOTES_MAX_LENGTH = 500
// setJournalMode() is added as part of issue #344
@@ -85,7 +92,7 @@ abstract class AppDatabase : RoomDatabase() {
// with the 16-byte magic string "SQLite format 3\0". Files failing this check are
// treated as corrupt/truncated so the pre-packaged asset can be re-copied by Room's
// createFromAsset() instead of being reused as-is.
- private fun isValidSQLiteFile(file: java.io.File): Boolean {
+ internal fun isValidSQLiteFile(file: java.io.File): Boolean {
if (file.length() < 100) return false
return try {
java.io.RandomAccessFile(file, "r").use { raf ->
@@ -172,6 +179,9 @@ abstract class AppDatabase : RoomDatabase() {
.addMigrations(MIGRATION_29_30)
.addMigrations(MIGRATION_30_31)
.addMigrations(MIGRATION_31_32)
+ .addMigrations(MIGRATION_32_33)
+ .addMigrations(MIGRATION_33_34)
+ .addMigrations(MIGRATION_34_35)
.build()
private val roomCallback: Callback =
@@ -181,7 +191,6 @@ abstract class AppDatabase : RoomDatabase() {
createAppInfoNotesLengthTriggers(db)
Logger.i(LOG_TAG_APP_DB, "Database created, ${db.version}")
}
-
override fun onDestructiveMigration(db: SupportSQLiteDatabase) {
super.onDestructiveMigration(db)
Logger.i(LOG_TAG_APP_DB, "Database destructively migrated, ${db.version}")
@@ -189,9 +198,25 @@ abstract class AppDatabase : RoomDatabase() {
override fun onOpen(db: SupportSQLiteDatabase) {
super.onOpen(db)
+ setJournalSizeLimit(db)
Logger.i(LOG_TAG_APP_DB, "Database opened, ${db.version}")
}
}
+
+ // PRAGMA journal_size_limit sets *and* returns the new limit, i.e. it is a
+ // result-returning pragma; SQLiteDatabase.execSQL() rejects such statements
+ // ("Queries can be performed using SQLiteDatabase query or rawQuery methods
+ // only"), so it must run through the query path with the cursor drained.
+ private fun setJournalSizeLimit(db: SupportSQLiteDatabase) {
+ try {
+ db.query(
+ SimpleSQLiteQuery("PRAGMA journal_size_limit = $JOURNAL_SIZE_LIMIT_BYTES")
+ ).use { it.moveToFirst() }
+ } catch (e: Exception) {
+ // non-fatal: without the limit the WAL simply keeps its high-water mark
+ Logger.w(LOG_TAG_APP_DB, "err setting journal_size_limit: ${e.message}", e)
+ }
+ }
private fun createAppInfoNotesLengthTriggers(db: SupportSQLiteDatabase) {
db.execSQL(
"CREATE TRIGGER IF NOT EXISTS trg_appinfo_notes_length_insert " +
@@ -1319,7 +1344,7 @@ abstract class AppDatabase : RoomDatabase() {
}
}
- private val MIGRATION_30_31: Migration =
+ val MIGRATION_30_31: Migration =
object : Migration(30, 31) {
override fun migrate(db: SupportSQLiteDatabase) {
if (!doesColumnExistInTable(db, "AppInfo", "notes")) {
@@ -1335,14 +1360,48 @@ abstract class AppDatabase : RoomDatabase() {
} else {
Logger.i(LOG_TAG_APP_DB, "MIGRATION_30_31: notes column already exists in AppInfo")
}
+
+ createSponsorTable(db)
}
}
+
+ private fun createSponsorTable(db: SupportSQLiteDatabase) {
+ try {
+ db.execSQL(
+ "CREATE TABLE IF NOT EXISTS Sponsor (" +
+ "id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
+ "purchase_token TEXT NOT NULL, " +
+ "product_id TEXT NOT NULL, " +
+ "purchase_time INTEGER NOT NULL, " +
+ "sponsor_since INTEGER NOT NULL, " +
+ "consumed INTEGER NOT NULL DEFAULT 1, " +
+ "contribution_count INTEGER NOT NULL DEFAULT 1, " +
+ "last_contribution_time INTEGER NOT NULL DEFAULT 0)"
+ )
+ Logger.i(LOG_TAG_APP_DB, "created Sponsor table")
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_APP_DB, "failed to create Sponsor table", e)
+ throw e
+ }
+ }
private val MIGRATION_31_32: Migration =
object : Migration(31, 32) {
override fun migrate(db: SupportSQLiteDatabase) {
- db.execSQL(
- "ALTER TABLE AppInfo ADD COLUMN notes TEXT NOT NULL DEFAULT ''"
- )
+ try {
+ db.execSQL(
+ "ALTER TABLE AppInfo ADD COLUMN notes TEXT NOT NULL DEFAULT ''"
+ )
+ Logger.i(LOG_TAG_APP_DB, "MIGRATION_31_32: added AppInfo.notes")
+ } catch (e: Exception) {
+ if (!e.message.orEmpty().contains("duplicate column name: notes", ignoreCase = true)) {
+ Logger.e(LOG_TAG_APP_DB, "MIGRATION_31_32: failed to add notes column", e)
+ throw e
+ }
+ Logger.i(
+ LOG_TAG_APP_DB,
+ "MIGRATION_31_32: notes column already exists in AppInfo, skip"
+ )
+ }
createAppInfoNotesLengthTriggers(db)
@@ -1353,6 +1412,170 @@ abstract class AppDatabase : RoomDatabase() {
}
}
+ private val MIGRATION_32_33: Migration =
+ object : Migration(32, 33) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ if (!doesColumnExistInTable(db, "DoHEndpoint", "dohIp")) {
+ try {
+ db.execSQL("ALTER TABLE DoHEndpoint ADD COLUMN dohIp TEXT")
+ Logger.i(
+ LOG_TAG_APP_DB,
+ "MIGRATION_32_33: added dohIp column to DoHEndpoint"
+ )
+ } catch (e: Exception) {
+ Logger.e(
+ LOG_TAG_APP_DB,
+ "MIGRATION_32_33: failed to add dohIp column",
+ e
+ )
+ throw e
+ }
+ } else {
+ Logger.i(
+ LOG_TAG_APP_DB,
+ "MIGRATION_32_33: dohIp column already exists in DoHEndpoint"
+ )
+ }
+ }
+ }
+
+ private val MIGRATION_33_34: Migration =
+ object : Migration(33, 34) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ createSmartDnsTable(db)
+ addSmartDnsEndpoints(db)
+ }
+
+ private fun createSmartDnsTable(db: SupportSQLiteDatabase) {
+ db.execSQL(
+ "CREATE TABLE IF NOT EXISTS 'SmartDnsEndpoint' " +
+ "('id' INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
+ "'dnsName' TEXT NOT NULL, " +
+ "'dnsMode' INTEGER NOT NULL, " +
+ "'dnsExplanation' TEXT NOT NULL, " +
+ "'isSelected' INTEGER NOT NULL, " +
+ "'modifiedDataTime' INTEGER NOT NULL, " +
+ "'latency' INTEGER NOT NULL)"
+ )
+ Logger.i(LOG_TAG_APP_DB, "MIGRATION_33_34: created SmartDnsEndpoint table")
+ }
+
+ // add the three smart dns options; none is selected until the user
+ // explicitly picks one from the smart dns list screen
+ private fun addSmartDnsEndpoints(db: SupportSQLiteDatabase) {
+ with(db) {
+ execSQL(
+ "INSERT OR REPLACE INTO SmartDnsEndpoint" +
+ "(id, dnsName, dnsMode, dnsExplanation, isSelected, modifiedDataTime, latency) " +
+ "VALUES (1, 'Unfiltered', 0, 'Prefers any of the default DNS resolvers without applying any filtering.', 0, 0, 0)"
+ )
+ execSQL(
+ "INSERT OR REPLACE INTO SmartDnsEndpoint" +
+ "(id, dnsName, dnsMode, dnsExplanation, isSelected, modifiedDataTime, latency) " +
+ "VALUES (2, 'Security', 1, 'Prefers resolvers blocking malware, ransomware, phishers, and other threats.', 0, 0, 0)"
+ )
+ execSQL(
+ "INSERT OR REPLACE INTO SmartDnsEndpoint" +
+ "(id, dnsName, dnsMode, dnsExplanation, isSelected, modifiedDataTime, latency) " +
+ "VALUES (3, 'Privacy', 2, 'Prefers resolvers blocking attentionware, spyware, scareware.', 0, 0, 0)"
+ )
+ execSQL(
+ "INSERT OR REPLACE INTO SmartDnsEndpoint" +
+ "(id, dnsName, dnsMode, dnsExplanation, isSelected, modifiedDataTime, latency) " +
+ "VALUES (4, 'Family', 3, 'Prefers resolvers blocking adult and pirated content.', 0, 0, 0)"
+ )
+ }
+ Logger.i(LOG_TAG_APP_DB, "MIGRATION_33_34: seeded SmartDnsEndpoint rows")
+ }
+ }
+
+ // migration part of v057:
+ // 1. replace Mullvad DoT endpoints with Control-D in-place, preserving the
+ // user's selection (an in-place update that does not touch isSelected
+ // carries it over automatically)
+ // 2. add ControlD Security (DoH, p1) as a default (non-deletable) endpoint
+ // 3. add DNS4U Extended (DoT) and DNS4U Privacy (DoH) as default endpoints
+ // 4. refresh Quad9 DNSCrypt stamps as per upstream dnscrypt-resolvers list
+ // ref: github.com/DNSCrypt/dnscrypt-resolvers/blob/master/v3/public-resolvers.md
+ private val MIGRATION_34_35: Migration =
+ object : Migration(34, 35) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ replaceMullvadWithControlD(db)
+ addControlDDefaultDoHEndpoint(db)
+ updateQuad9DnsCryptStamps(db)
+ }
+
+ private fun replaceMullvadWithControlD(db: SupportSQLiteDatabase) {
+ with(db) {
+ execSQL(
+ "UPDATE DoTEndpoint SET name = 'ControlD Privacy', " +
+ "url = 'tls://p2.freedns.controld.com', " +
+ "desc = 'Blocks spyware and tracking domains.', isCustom = 0 " +
+ "WHERE id = 3 AND url = 'tls://adblock.dns.mullvad.net'"
+ )
+ execSQL(
+ "UPDATE DoTEndpoint SET name = 'ControlD Extended', " +
+ "url = 'tls://p3.freedns.controld.com', " +
+ "desc = 'Blocks malware, spyware, social media and tracking " +
+ "domains.', isCustom = 0 " +
+ "WHERE id = 4 AND url = 'tls://extended.dns.mullvad.net'"
+ )
+ // remove any stray mullvad rows not covered by the in-place update
+ execSQL(
+ "DELETE FROM DoTEndpoint WHERE url IN " +
+ "('tls://adblock.dns.mullvad.net', 'tls://extended.dns.mullvad.net')"
+ )
+ // seed the defaults if the mullvad rows were already absent;
+ // isCustom = 0 makes them non-deletable from the ui
+ execSQL(
+ "INSERT OR IGNORE INTO DoTEndpoint(id, name, url, desc, isSelected, " +
+ "isCustom, isSecure, latency, modifiedDataTime) " +
+ "VALUES(3, 'ControlD Privacy', 'tls://p2.freedns.controld.com', " +
+ "'Blocks spyware and tracking domains.', " +
+ "0, 0, 1, 0, 0)"
+ )
+ execSQL(
+ "INSERT OR IGNORE INTO DoTEndpoint(id, name, url, desc, isSelected, " +
+ "isCustom, isSecure, latency, modifiedDataTime) " +
+ "VALUES(4, 'ControlD Extended', 'tls://p3.freedns.controld.com', " +
+ "'Blocks malware, spyware, social media, and tracking domains.', " +
+ "0, 0, 1, 0, 0)"
+ )
+ }
+ }
+
+ // control-d security (doh, p1) as a default endpoint; no explicit id,
+ // mirroring how default doh entries are seeded in MIGRATION_11_12
+ private fun addControlDDefaultDoHEndpoint(db: SupportSQLiteDatabase) {
+ with(db) {
+ execSQL(
+ "INSERT INTO DoHEndpoint(dohName, dohURL, dohExplanation, isSelected, " +
+ "isCustom, isSecure, modifiedDataTime, latency) " +
+ "VALUES('ControlD Security', 'https://freedns.controld.com/p1', " +
+ "'Blocks malware and malicious domains.', " +
+ "0, 0, 1, 0, 0)"
+ )
+ }
+ }
+
+
+ // update quad9 dns crypt stamps as per the upstream public-resolvers list
+ private fun updateQuad9DnsCryptStamps(db: SupportSQLiteDatabase) {
+ with(db) {
+ execSQL(
+ "UPDATE DNSCryptEndpoint SET dnsCryptURL = " +
+ "'sdns://AQMAAAAAAAAADDkuOS45Ljk6ODQ0MyBnyEe4yHWM0SAkVUO-dWdG3zTfHYTAC4xHA2jfgh2GPhkyLmRuc2NyeXB0LWNlcnQucXVhZDkubmV0' " +
+ "WHERE id = 4 AND dnsCryptName = 'Quad9 Security'"
+ )
+ execSQL(
+ "UPDATE DNSCryptEndpoint SET dnsCryptURL = " +
+ "'sdns://AQcAAAAAAAAADTkuOS45LjEwOjg0NDMgZ8hHuMh1jNEgJFVDvnVnRt803x2EwAuMRwNo34Idhj4ZMi5kbnNjcnlwdC1jZXJ0LnF1YWQ5Lm5ldA' " +
+ "WHERE id = 5 AND dnsCryptName = 'Quad9'"
+ )
+ }
+ }
+ }
+
// ref: stackoverflow.com/a/57204285
private fun doesColumnExistInTable(
db: SupportSQLiteDatabase,
@@ -1426,12 +1649,16 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun countryConfigDAO(): CountryConfigDAO
+ abstract fun smartDnsEndpointDao(): SmartDnsEndpointDAO
+
fun appInfoRepository() = AppInfoRepository(appInfoDAO())
fun dohEndpointRepository() = DoHEndpointRepository(dohEndpointsDAO())
fun countryConfigRepository() = CountryConfigRepository(countryConfigDAO())
+ fun smartDnsEndpointRepository() = SmartDnsEndpointRepository(smartDnsEndpointDao())
+
fun dnsCryptEndpointRepository() = DnsCryptEndpointRepository(dnsCryptEndpointDAO())
fun dnsCryptRelayEndpointRepository() =
diff --git a/app/src/main/java/com/celzero/bravedns/database/ConnectionTrackerDAO.kt b/app/src/main/java/com/celzero/bravedns/database/ConnectionTrackerDAO.kt
index fecd87d892..20c2f21882 100644
--- a/app/src/main/java/com/celzero/bravedns/database/ConnectionTrackerDAO.kt
+++ b/app/src/main/java/com/celzero/bravedns/database/ConnectionTrackerDAO.kt
@@ -26,6 +26,7 @@ import androidx.room.Update
import com.celzero.bravedns.data.AppConnection
import com.celzero.bravedns.data.DataUsage
import com.celzero.bravedns.data.DataUsageSummary
+import com.celzero.bravedns.data.RpnConnStatsSummary
private const val CT_COLUMNS =
"id, 'ct' as source, appName, uid, packageName, usrId, ipAddress, port, protocol, isBlocked, blockedByRule, blocklists, proxyDetails, flag, dnsQuery, timeStamp, connId, downloadBytes, uploadBytes, duration, synack, rpid, message, connType"
private const val RLOG_COLUMNS =
@@ -33,6 +34,19 @@ private const val RLOG_COLUMNS =
private const val SEARCH_PREDICATE =
"(appName like :query or ipAddress like :query or dnsQuery like :query or flag like :query or proxyDetails like :query or connId like :query)"
+// Per-arm row cap for merged (UNION ALL) queries. A compound `order by
+// timeStamp desc, id desc` cannot use indexes; without a cap SQLite scans and
+// sorts both tables in full for every page, which made NetworkLogsActivity slow
+// to open (and Room re-runs the query on every insert into either table).
+// Each arm below reads only the newest MERGE_SCAN_LIMIT rows via a reverse
+// rowid scan (`order by id desc limit N`), so the compound sort is bounded by
+// 2 * MERGE_SCAN_LIMIT rows. Logs are appended in near-chronological order, so
+// the newest N rows per arm contain the newest rows of the union. The cap
+// comfortably exceeds the paging window (pageSize 30, maxSize 180,
+// initialLoadSize 60 in ConnectionTrackerViewModel); scrolling past the cap
+// degrades to per-arm id order instead of global time order.
+private const val MERGE_SCAN_LIMIT = 2000
+
@Dao
interface ConnectionTrackerDAO {
@@ -44,6 +58,44 @@ interface ConnectionTrackerDAO {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertBatch(connTrackerList: List)
+ // bucket aggregation for the activity wall (LogActivityAggregator);
+ // bucketIndex = (timeStamp - rangeStart) / bucketMs, grouped per blocked
+ // classification. Pass bucketMs=600000 (10 min) for the trailing-24h
+ // ten-minute wall slots.
+ @Query(
+ "select cast((timeStamp - :rangeStart)/:bucketMs as integer) as bucketIndex, isBlocked as blocked, count(id) as total from ConnectionTracker where timeStamp >= :rangeStart and timeStamp < :rangeEnd group by bucketIndex, blocked"
+ )
+ suspend fun getActivityBuckets(
+ rangeStart: Long,
+ rangeEnd: Long,
+ bucketMs: Long
+ ): List
+
+ @Query(
+ "select coalesce(sum(case when isBlocked then 1 else 0 end), 0) as blocked, count(*) as total from ConnectionTracker where timeStamp >= :start and timeStamp < :end"
+ )
+ suspend fun getWindowCounts(start: Long, end: Long): WindowCountRow
+
+ @Query(
+ "select * from ConnectionTracker where timeStamp >= :start and timeStamp < :end order by id desc limit :limit"
+ )
+ suspend fun getConnectionsInWindow(start: Long, end: Long, limit: Int): List
+
+ @Query(
+ "select uid as uid, appName as appName, count(id) as total, sum(case when isBlocked then 1 else 0 end) as blocked from ConnectionTracker where timeStamp >= :start and timeStamp < :end group by uid, appName order by total desc limit :limit"
+ )
+ suspend fun getAppActivity(start: Long, end: Long, limit: Int): List
+
+ @Query(
+ "select * from ConnectionTracker where timeStamp >= :start and timeStamp < :end and uid = :uid order by id desc limit :limit"
+ )
+ suspend fun getConnectionsInWindowForUid(
+ start: Long,
+ end: Long,
+ uid: Int,
+ limit: Int
+ ): List
+
@Query(
"update ConnectionTracker set proxyDetails = :pid, rpid = :rpid, downloadBytes = :downloadBytes, uploadBytes = :uploadBytes, duration = :duration, synack = :synack, message = :message where connId = :connId"
)
@@ -154,34 +206,48 @@ interface ConnectionTrackerDAO {
// Merged queries: UNION ALL of ConnectionTracker and RethinkLog.
// These are read-only display queries; inserts remain unchanged.
// Column list must match MergedConnectionLog in name/order.
+ // Each arm is capped to the newest MERGE_SCAN_LIMIT rows (see the constant
+ // for why) so the compound sort is bounded instead of full-table.
@Query(
- "select $CT_COLUMNS from ConnectionTracker where isBlocked = 1 UNION ALL select $RLOG_COLUMNS from RethinkLog where isBlocked = 1 order by timeStamp desc, id desc"
+ "select * from (select $CT_COLUMNS from ConnectionTracker where isBlocked = 1 order by id desc limit $MERGE_SCAN_LIMIT) " +
+ "union all select * from (select $RLOG_COLUMNS from RethinkLog where isBlocked = 1 order by id desc limit $MERGE_SCAN_LIMIT) " +
+ "order by timeStamp desc, id desc"
)
fun getMergedBlockedConnections(): PagingSource
@Query(
- "select $CT_COLUMNS from ConnectionTracker where isBlocked = 1 and $SEARCH_PREDICATE UNION ALL select $RLOG_COLUMNS from RethinkLog where isBlocked = 1 and $SEARCH_PREDICATE order by timeStamp desc, id desc"
+ "select * from (select $CT_COLUMNS from ConnectionTracker where isBlocked = 1 and $SEARCH_PREDICATE order by id desc limit $MERGE_SCAN_LIMIT) " +
+ "union all select * from (select $RLOG_COLUMNS from RethinkLog where isBlocked = 1 and $SEARCH_PREDICATE order by id desc limit $MERGE_SCAN_LIMIT) " +
+ "order by timeStamp desc, id desc"
)
fun getMergedBlockedConnections(query: String): PagingSource
@Query(
- "select $CT_COLUMNS from ConnectionTracker where isBlocked = 0 UNION ALL select $RLOG_COLUMNS from RethinkLog where isBlocked = 0 order by timeStamp desc, id desc"
+ "select * from (select $CT_COLUMNS from ConnectionTracker where isBlocked = 0 order by id desc limit $MERGE_SCAN_LIMIT) " +
+ "union all select * from (select $RLOG_COLUMNS from RethinkLog where isBlocked = 0 order by id desc limit $MERGE_SCAN_LIMIT) " +
+ "order by timeStamp desc, id desc"
)
fun getMergedAllowedConnections(): PagingSource
@Query(
- "select $CT_COLUMNS from ConnectionTracker where isBlocked = 0 and $SEARCH_PREDICATE UNION ALL select $RLOG_COLUMNS from RethinkLog where isBlocked = 0 and $SEARCH_PREDICATE order by timeStamp desc, id desc"
+ "select * from (select $CT_COLUMNS from ConnectionTracker where isBlocked = 0 and $SEARCH_PREDICATE order by id desc limit $MERGE_SCAN_LIMIT) " +
+ "union all select * from (select $RLOG_COLUMNS from RethinkLog where isBlocked = 0 and $SEARCH_PREDICATE order by id desc limit $MERGE_SCAN_LIMIT) " +
+ "order by timeStamp desc, id desc"
)
fun getMergedAllowedConnections(query: String): PagingSource
@Query(
- "select $CT_COLUMNS from ConnectionTracker where blockedByRule in (:filter) and isBlocked = 1 UNION ALL select $RLOG_COLUMNS from RethinkLog where blockedByRule in (:filter) and isBlocked = 1 order by timeStamp desc, id desc"
+ "select * from (select $CT_COLUMNS from ConnectionTracker where blockedByRule in (:filter) and isBlocked = 1 order by id desc limit $MERGE_SCAN_LIMIT) " +
+ "union all select * from (select $RLOG_COLUMNS from RethinkLog where blockedByRule in (:filter) and isBlocked = 1 order by id desc limit $MERGE_SCAN_LIMIT) " +
+ "order by timeStamp desc, id desc"
)
fun getMergedBlockedConnectionsFiltered(filter: Set): PagingSource
@Query(
- "select $CT_COLUMNS from ConnectionTracker where blockedByRule in (:filter) and isBlocked = 1 and $SEARCH_PREDICATE UNION ALL select $RLOG_COLUMNS from RethinkLog where blockedByRule in (:filter) and isBlocked = 1 and $SEARCH_PREDICATE order by timeStamp desc, id desc"
+ "select * from (select $CT_COLUMNS from ConnectionTracker where blockedByRule in (:filter) and isBlocked = 1 and $SEARCH_PREDICATE order by id desc limit $MERGE_SCAN_LIMIT) " +
+ "union all select * from (select $RLOG_COLUMNS from RethinkLog where blockedByRule in (:filter) and isBlocked = 1 and $SEARCH_PREDICATE order by id desc limit $MERGE_SCAN_LIMIT) " +
+ "order by timeStamp desc, id desc"
)
fun getMergedBlockedConnectionsFiltered(
query: String,
@@ -189,12 +255,16 @@ interface ConnectionTrackerDAO {
): PagingSource
@Query(
- "select $CT_COLUMNS from ConnectionTracker where blockedByRule in (:filter) and isBlocked = 0 UNION ALL select $RLOG_COLUMNS from RethinkLog where blockedByRule in (:filter) and isBlocked = 0 order by timeStamp desc, id desc"
+ "select * from (select $CT_COLUMNS from ConnectionTracker where blockedByRule in (:filter) and isBlocked = 0 order by id desc limit $MERGE_SCAN_LIMIT) " +
+ "union all select * from (select $RLOG_COLUMNS from RethinkLog where blockedByRule in (:filter) and isBlocked = 0 order by id desc limit $MERGE_SCAN_LIMIT) " +
+ "order by timeStamp desc, id desc"
)
fun getMergedAllowedConnectionsFiltered(filter: Set): PagingSource
@Query(
- "select $CT_COLUMNS from ConnectionTracker where blockedByRule in (:filter) and isBlocked = 0 and $SEARCH_PREDICATE UNION ALL select $RLOG_COLUMNS from RethinkLog where blockedByRule in (:filter) and isBlocked = 0 and $SEARCH_PREDICATE order by timeStamp desc, id desc"
+ "select * from (select $CT_COLUMNS from ConnectionTracker where blockedByRule in (:filter) and isBlocked = 0 and $SEARCH_PREDICATE order by id desc limit $MERGE_SCAN_LIMIT) " +
+ "union all select * from (select $RLOG_COLUMNS from RethinkLog where blockedByRule in (:filter) and isBlocked = 0 and $SEARCH_PREDICATE order by id desc limit $MERGE_SCAN_LIMIT) " +
+ "order by timeStamp desc, id desc"
)
fun getMergedAllowedConnectionsFiltered(
query: String,
@@ -202,12 +272,16 @@ interface ConnectionTrackerDAO {
): PagingSource
@Query(
- "select $CT_COLUMNS from ConnectionTracker where protocol = :protocol UNION ALL select $RLOG_COLUMNS from RethinkLog where protocol = :protocol order by timeStamp desc, id desc"
+ "select * from (select $CT_COLUMNS from ConnectionTracker where protocol = :protocol order by id desc limit $MERGE_SCAN_LIMIT) " +
+ "union all select * from (select $RLOG_COLUMNS from RethinkLog where protocol = :protocol order by id desc limit $MERGE_SCAN_LIMIT) " +
+ "order by timeStamp desc, id desc"
)
fun getMergedProtocolFilteredConnections(protocol: String): PagingSource
@Query(
- "select $CT_COLUMNS from ConnectionTracker where protocol = :protocol and blockedByRule in (:filter) UNION ALL select $RLOG_COLUMNS from RethinkLog where protocol = :protocol and blockedByRule in (:filter) order by timeStamp desc, id desc"
+ "select * from (select $CT_COLUMNS from ConnectionTracker where protocol = :protocol and blockedByRule in (:filter) order by id desc limit $MERGE_SCAN_LIMIT) " +
+ "union all select * from (select $RLOG_COLUMNS from RethinkLog where protocol = :protocol and blockedByRule in (:filter) order by id desc limit $MERGE_SCAN_LIMIT) " +
+ "order by timeStamp desc, id desc"
)
fun getMergedProtocolFilteredConnections(
protocol: String,
@@ -215,12 +289,16 @@ interface ConnectionTrackerDAO {
): PagingSource
@Query(
- "select $CT_COLUMNS from ConnectionTracker UNION ALL select $RLOG_COLUMNS from RethinkLog order by timeStamp desc, id desc"
+ "select * from (select $CT_COLUMNS from ConnectionTracker order by id desc limit $MERGE_SCAN_LIMIT) " +
+ "union all select * from (select $RLOG_COLUMNS from RethinkLog order by id desc limit $MERGE_SCAN_LIMIT) " +
+ "order by timeStamp desc, id desc"
)
fun getMergedConnectionTrackerByName(): PagingSource
@Query(
- "select $CT_COLUMNS from ConnectionTracker where $SEARCH_PREDICATE UNION ALL select $RLOG_COLUMNS from RethinkLog where $SEARCH_PREDICATE order by timeStamp desc, id desc"
+ "select * from (select $CT_COLUMNS from ConnectionTracker where $SEARCH_PREDICATE order by id desc limit $MERGE_SCAN_LIMIT) " +
+ "union all select * from (select $RLOG_COLUMNS from RethinkLog where $SEARCH_PREDICATE order by id desc limit $MERGE_SCAN_LIMIT) " +
+ "order by timeStamp desc, id desc"
)
fun getMergedConnectionTrackerByName(query: String): PagingSource
@@ -302,11 +380,70 @@ interface ConnectionTrackerDAO {
@Query("SELECT uid AS uid, '' AS ipAddress, 0 AS port, COUNT(id) AS count, flag AS flag, 0 AS blocked, appName AS appOrDnsName, SUM(downloadBytes) AS downloadBytes, SUM(uploadBytes) AS uploadBytes, SUM(uploadBytes + downloadBytes) AS totalBytes FROM ConnectionTracker WHERE proxyDetails like :wgId AND timeStamp > :to GROUP BY appName ORDER BY totalBytes DESC")
fun getWgAppNetworkActivity(wgId: String, to: Long): PagingSource
+ // last app routed through a proxy (RPN/WG). proxyDetails holds the proxy id
+ // (e.g. Backend.RpnWin + configKey); the wildcard match mirrors the filter used
+ // by ConnectionTrackerFragment when navigated from the RPN detail screen.
+ @Query("select * from ConnectionTracker where proxyDetails like '%' || :proxyId || '%' and isBlocked = 0 and appName != '%Unknown%' order by timeStamp desc limit 1")
+ suspend fun getLastRoutedConnectionForProxy(proxyId: String): ConnectionTracker?
+
+ @Query("select * from ConnectionTracker where proxyDetails like '%' || :proxyId || '%' and isBlocked = 0 and appName != '%Unknown%' order by timeStamp desc limit 24")
+ suspend fun getRecentRoutedConnectionsForProxy(proxyId: String): List
+
@Query(
"select sum(downloadBytes) as totalDownload, sum(uploadBytes) as totalUpload, count(id) as connectionsCount, ict.meteredDataUsage as meteredDataUsage from ConnectionTracker as ct join (select sum(downloadBytes + uploadBytes) as meteredDataUsage from ConnectionTracker where connType like :meteredTxt and timeStamp > :to) as ict where timeStamp > :to and proxyDetails = :wgId"
)
fun getTotalUsagesByWgId(to: Long, meteredTxt: String, wgId: String): DataUsageSummary
+ // Aggregate stats for connections routed through an RPN proxy within a time
+ // window. proxyDetails is matched with wildcards (mirrors getLastRoutedConnectionForProxy).
+ @Query(
+ "select count(id) as connectionsCount, coalesce(sum(downloadBytes), 0) as totalDownload, coalesce(sum(uploadBytes), 0) as totalUpload, coalesce(sum(isBlocked), 0) as blockedCount, count(distinct appName) as appCount from ConnectionTracker where proxyDetails like '%' || :proxyId || '%' and timeStamp > :to"
+ )
+ suspend fun getRpnConnStats(proxyId: String, to: Long): RpnConnStatsSummary
+
+ // Top apps (by total bytes) with connections routed through an RPN proxy.
+ @Query(
+ "select uid as uid, '' as ipAddress, 0 as port, count(id) as count, '' as flag, 0 as blocked, appName as appOrDnsName, sum(downloadBytes) as downloadBytes, sum(uploadBytes) as uploadBytes, sum(downloadBytes + uploadBytes) as totalBytes from ConnectionTracker where proxyDetails like '%' || :proxyId || '%' and isBlocked = 0 and timeStamp > :to group by uid, appName order by totalBytes desc limit :limit"
+ )
+ suspend fun getRpnTopAppsForProxy(proxyId: String, to: Long, limit: Int): List
+
+ // bucketed activity counts for connections routed through RPN proxies only.
+ @Query(
+ "select cast((timeStamp - :rangeStart)/:bucketMs as integer) as bucketIndex, isBlocked as blocked, count(id) as total from ConnectionTracker where timeStamp >= :rangeStart and timeStamp < :rangeEnd and proxyDetails like :proxyIdFilter group by bucketIndex, blocked"
+ )
+ suspend fun getRpnActivityBuckets(
+ proxyIdFilter: String,
+ rangeStart: Long,
+ rangeEnd: Long,
+ bucketMs: Long
+ ): List
+
+ @Query(
+ "select coalesce(sum(case when isBlocked then 1 else 0 end), 0) as blocked, count(*) as total from ConnectionTracker where timeStamp >= :start and timeStamp < :end and proxyDetails like :proxyIdFilter"
+ )
+ suspend fun getRpnWindowCounts(proxyIdFilter: String, start: Long, end: Long): WindowCountRow
+
+ @Query(
+ "select uid as uid, appName as appName, count(id) as total, sum(case when isBlocked then 1 else 0 end) as blocked from ConnectionTracker where timeStamp >= :start and timeStamp < :end and proxyDetails like :proxyIdFilter group by uid, appName order by total desc limit :limit"
+ )
+ suspend fun getRpnAppActivity(
+ proxyIdFilter: String,
+ start: Long,
+ end: Long,
+ limit: Int
+ ): List
+
+ @Query(
+ "select * from ConnectionTracker where timeStamp >= :start and timeStamp < :end and uid = :uid and proxyDetails like :proxyIdFilter order by id desc limit :limit"
+ )
+ suspend fun getRpnConnectionsInWindowForUid(
+ proxyIdFilter: String,
+ start: Long,
+ end: Long,
+ uid: Int,
+ limit: Int
+ ): List
+
@Query("update ConnectionTracker set message = :reason, duration = 0 where connId in (:connIds) and message = '' and uploadBytes = 0 and downloadBytes = 0 and synack = 0")
fun closeConnections(connIds: List, reason: String)
diff --git a/app/src/main/java/com/celzero/bravedns/database/ConnectionTrackerRepository.kt b/app/src/main/java/com/celzero/bravedns/database/ConnectionTrackerRepository.kt
index 31ad0bebc4..357b6416c2 100644
--- a/app/src/main/java/com/celzero/bravedns/database/ConnectionTrackerRepository.kt
+++ b/app/src/main/java/com/celzero/bravedns/database/ConnectionTrackerRepository.kt
@@ -22,9 +22,7 @@ import com.celzero.bravedns.service.PersistentState
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
-class ConnectionTrackerRepository(private val connectionTrackerDAO: ConnectionTrackerDAO): KoinComponent {
-
- private val persistentState by inject()
+class ConnectionTrackerRepository(private val connectionTrackerDAO: ConnectionTrackerDAO) {
suspend fun insert(connectionTracker: ConnectionTracker) {
connectionTrackerDAO.insert(connectionTracker)
@@ -101,6 +99,13 @@ class ConnectionTrackerRepository(private val connectionTrackerDAO: ConnectionTr
return connectionTrackerDAO.getBlockedUniversalRulesCount()
}
+ suspend fun getRecentRoutedAppsForProxy(proxyId: String, limit: Int = 3): List {
+ val seen = mutableSetOf()
+ return connectionTrackerDAO.getRecentRoutedConnectionsForProxy(proxyId)
+ .filter { seen.add(it.appName) }
+ .take(limit)
+ }
+
suspend fun closeConnections( connIds: List, reason: String) {
connectionTrackerDAO.closeConnections(connIds, reason)
}
@@ -109,6 +114,78 @@ class ConnectionTrackerRepository(private val connectionTrackerDAO: ConnectionTr
connectionTrackerDAO.closeConnectionForUids(uids, reason)
}
+ suspend fun getActivityBuckets(
+ rangeStart: Long,
+ rangeEnd: Long,
+ bucketMs: Long
+ ): List {
+ return connectionTrackerDAO.getActivityBuckets(rangeStart, rangeEnd, bucketMs)
+ }
+
+ suspend fun getWindowCounts(start: Long, end: Long): WindowCountRow {
+ return connectionTrackerDAO.getWindowCounts(start, end)
+ }
+
+ suspend fun getAppActivity(start: Long, end: Long, limit: Int): List {
+ return connectionTrackerDAO.getAppActivity(start, end, limit)
+ }
+
+ suspend fun getConnectionsInWindowForUid(
+ start: Long,
+ end: Long,
+ uid: Int,
+ limit: Int
+ ): List {
+ return connectionTrackerDAO.getConnectionsInWindowForUid(start, end, uid, limit)
+ }
+
+ suspend fun getRpnActivityBuckets(
+ proxyIdFilter: String,
+ rangeStart: Long,
+ rangeEnd: Long,
+ bucketMs: Long
+ ): List {
+ return connectionTrackerDAO.getRpnActivityBuckets(
+ proxyIdFilter,
+ rangeStart,
+ rangeEnd,
+ bucketMs
+ )
+ }
+
+ suspend fun getRpnWindowCounts(
+ proxyIdFilter: String,
+ start: Long,
+ end: Long
+ ): WindowCountRow {
+ return connectionTrackerDAO.getRpnWindowCounts(proxyIdFilter, start, end)
+ }
+
+ suspend fun getRpnAppActivity(
+ proxyIdFilter: String,
+ start: Long,
+ end: Long,
+ limit: Int
+ ): List {
+ return connectionTrackerDAO.getRpnAppActivity(proxyIdFilter, start, end, limit)
+ }
+
+ suspend fun getRpnConnectionsInWindowForUid(
+ proxyIdFilter: String,
+ start: Long,
+ end: Long,
+ uid: Int,
+ limit: Int
+ ): List {
+ return connectionTrackerDAO.getRpnConnectionsInWindowForUid(
+ proxyIdFilter,
+ start,
+ end,
+ uid,
+ limit
+ )
+ }
+
private val BLOCKED_WINDOW_MS = 5 * 60 * 1000L // 5 minutes
fun getBlockedConnectionsCountLiveData(): LiveData {
val since = System.currentTimeMillis() - BLOCKED_WINDOW_MS
diff --git a/app/src/main/java/com/celzero/bravedns/database/CountryConfig.kt b/app/src/main/java/com/celzero/bravedns/database/CountryConfig.kt
index f40793e5b8..d049be3657 100644
--- a/app/src/main/java/com/celzero/bravedns/database/CountryConfig.kt
+++ b/app/src/main/java/com/celzero/bravedns/database/CountryConfig.kt
@@ -147,6 +147,10 @@ data class CountryConfig(
}
private fun countryDisplayName(cc: String): String {
- return try { Locale("", cc).displayCountry.ifBlank { cc } } catch (_: Throwable) { cc }
+ return try {
+ Locale("", cc).displayCountry.ifBlank {
+ if (cc.equals("AUTO", ignoreCase = true)) "Auto" else cc
+ }
+ } catch (_: Throwable) { cc }
}
}
diff --git a/app/src/main/java/com/celzero/bravedns/database/DatabaseModule.kt b/app/src/main/java/com/celzero/bravedns/database/DatabaseModule.kt
index 1161cffbb7..b489dbb346 100644
--- a/app/src/main/java/com/celzero/bravedns/database/DatabaseModule.kt
+++ b/app/src/main/java/com/celzero/bravedns/database/DatabaseModule.kt
@@ -51,6 +51,7 @@ object DatabaseModule {
single { get().subscriptionStatusDao() }
single { get().subscriptionStateHistoryDao()}
single { get().countryConfigDAO() }
+ single { get().smartDnsEndpointDao() }
single { get().connectionTrackerDAO() }
single { get().dnsLogDAO() }
@@ -89,6 +90,7 @@ object DatabaseModule {
single { get().subscriptionStatusRepository() }
single { get().subscriptionStateHistoryDao() }
single { get().countryConfigRepository() }
+ single { get().smartDnsEndpointRepository() }
single { get().rethinkConnectionLogRepository() }
single { get().connectionTrackerRepository() }
diff --git a/app/src/main/java/com/celzero/bravedns/database/DnsLogDAO.kt b/app/src/main/java/com/celzero/bravedns/database/DnsLogDAO.kt
index b9ed4e5317..fdd73d68ea 100644
--- a/app/src/main/java/com/celzero/bravedns/database/DnsLogDAO.kt
+++ b/app/src/main/java/com/celzero/bravedns/database/DnsLogDAO.kt
@@ -114,6 +114,39 @@ interface DnsLogDAO {
"SELECT uid AS uid, MAX(time) AS lastBlocked, COUNT(*) AS count FROM DNSLogs WHERE isBlocked = 1 AND time > :time GROUP BY uid ORDER BY lastBlocked DESC"
)
fun getRecentlyBlockedDnsAppsPaged(time: Long): PagingSource
+
+ // bucket aggregation for the activity wall (LogActivityAggregator);
+ // bucketIndex = (time - rangeStart) / bucketMs, grouped per blocked
+ // classification. Pass bucketMs=600000 (10 min) for the trailing-24h
+ // ten-minute wall slots.
+ @Query(
+ "select cast((time - :rangeStart)/:bucketMs as integer) as bucketIndex, isBlocked as blocked, count(id) as total from DNSLogs where time >= :rangeStart and time < :rangeEnd group by bucketIndex, blocked"
+ )
+ suspend fun getActivityBuckets(
+ rangeStart: Long,
+ rangeEnd: Long,
+ bucketMs: Long
+ ): List
+
+ @Query(
+ "select coalesce(sum(case when isBlocked then 1 else 0 end), 0) as blocked, count(*) as total from DNSLogs where time >= :start and time < :end"
+ )
+ suspend fun getWindowCounts(start: Long, end: Long): WindowCountRow
+
+ @Query(
+ "select * from DNSLogs where time >= :start and time < :end order by id desc limit :limit"
+ )
+ suspend fun getDnsLogsInWindow(start: Long, end: Long, limit: Int): List
+
+ @Query(
+ "select uid as uid, appName as appName, count(id) as total, sum(case when isBlocked then 1 else 0 end) as blocked from DNSLogs where time >= :start and time < :end group by uid, appName order by total desc limit :limit"
+ )
+ suspend fun getAppActivity(start: Long, end: Long, limit: Int): List
+
+ @Query(
+ "select * from DNSLogs where time >= :start and time < :end and uid = :uid order by id desc limit :limit"
+ )
+ suspend fun getDnsLogsInWindowForUid(start: Long, end: Long, uid: Int, limit: Int): List
}
data class BlockedDnsAppResult(
diff --git a/app/src/main/java/com/celzero/bravedns/database/DnsLogRepository.kt b/app/src/main/java/com/celzero/bravedns/database/DnsLogRepository.kt
index 72c604bfff..1241268ffb 100644
--- a/app/src/main/java/com/celzero/bravedns/database/DnsLogRepository.kt
+++ b/app/src/main/java/com/celzero/bravedns/database/DnsLogRepository.kt
@@ -42,4 +42,33 @@ class DnsLogRepository(private val dnsLogDAO: DnsLogDAO) {
fun getLeastLoggedTime(): Long {
return dnsLogDAO.getLeastLoggedTime()
}
+
+ suspend fun getActivityBuckets(
+ rangeStart: Long,
+ rangeEnd: Long,
+ bucketMs: Long
+ ): List {
+ return dnsLogDAO.getActivityBuckets(rangeStart, rangeEnd, bucketMs)
+ }
+
+ suspend fun getWindowCounts(start: Long, end: Long): WindowCountRow {
+ return dnsLogDAO.getWindowCounts(start, end)
+ }
+
+ suspend fun getDnsLogsInWindow(start: Long, end: Long, limit: Int): List {
+ return dnsLogDAO.getDnsLogsInWindow(start, end, limit)
+ }
+
+ suspend fun getAppActivity(start: Long, end: Long, limit: Int): List {
+ return dnsLogDAO.getAppActivity(start, end, limit)
+ }
+
+ suspend fun getDnsLogsInWindowForUid(
+ start: Long,
+ end: Long,
+ uid: Int,
+ limit: Int
+ ): List {
+ return dnsLogDAO.getDnsLogsInWindowForUid(start, end, uid, limit)
+ }
}
diff --git a/app/src/main/java/com/celzero/bravedns/database/DoHEndpoint.kt b/app/src/main/java/com/celzero/bravedns/database/DoHEndpoint.kt
index 9713fbbd20..43ce53d12b 100644
--- a/app/src/main/java/com/celzero/bravedns/database/DoHEndpoint.kt
+++ b/app/src/main/java/com/celzero/bravedns/database/DoHEndpoint.kt
@@ -25,6 +25,8 @@ class DoHEndpoint {
@PrimaryKey(autoGenerate = true) var id: Int = 0
var dohName: String = ""
var dohURL: String = ""
+ // user-supplied IP address (or comma-separated IPs) for the DoH URL; null if not set
+ var dohIp: String? = null
var dohExplanation: String? = null
var isSelected: Boolean = true
var isCustom: Boolean = true
@@ -49,6 +51,7 @@ class DoHEndpoint {
id: Int,
dohName: String,
dohURL: String,
+ dohIp: String?,
dohExplanation: String?,
isSelected: Boolean,
isCustom: Boolean,
@@ -61,6 +64,7 @@ class DoHEndpoint {
this.id = id
this.dohName = dohName
this.dohURL = dohURL
+ this.dohIp = dohIp
this.dohExplanation = dohExplanation
this.isSelected = isSelected
this.isCustom = isCustom
diff --git a/app/src/main/java/com/celzero/bravedns/database/DoHEndpointDAO.kt b/app/src/main/java/com/celzero/bravedns/database/DoHEndpointDAO.kt
index e91a250fb1..fa1bc7dcfd 100644
--- a/app/src/main/java/com/celzero/bravedns/database/DoHEndpointDAO.kt
+++ b/app/src/main/java/com/celzero/bravedns/database/DoHEndpointDAO.kt
@@ -37,7 +37,7 @@ interface DoHEndpointDAO {
@Delete fun delete(doHEndpoint: DoHEndpoint)
@Transaction
- @Query("select * from DoHEndpoint order by isSelected desc")
+ @Query("select * from DoHEndpoint order by isSelected desc, isCustom asc")
fun getDoHEndpointLiveData(): PagingSource
@Transaction
diff --git a/app/src/main/java/com/celzero/bravedns/database/DoTEndpointDAO.kt b/app/src/main/java/com/celzero/bravedns/database/DoTEndpointDAO.kt
index b77ec621fb..e565338a7f 100644
--- a/app/src/main/java/com/celzero/bravedns/database/DoTEndpointDAO.kt
+++ b/app/src/main/java/com/celzero/bravedns/database/DoTEndpointDAO.kt
@@ -37,7 +37,7 @@ interface DoTEndpointDAO {
@Delete fun delete(endpoint: DoTEndpoint)
@Transaction
- @Query("select * from DoTEndpoint order by isSelected desc")
+ @Query("select * from DoTEndpoint order by isSelected desc, isCustom asc")
fun getDoTEndpointLiveData(): PagingSource
@Transaction
diff --git a/app/src/main/java/com/celzero/bravedns/database/LogDatabase.kt b/app/src/main/java/com/celzero/bravedns/database/LogDatabase.kt
index 8e93fa77ac..6d71d3d9b4 100644
--- a/app/src/main/java/com/celzero/bravedns/database/LogDatabase.kt
+++ b/app/src/main/java/com/celzero/bravedns/database/LogDatabase.kt
@@ -41,6 +41,11 @@ abstract class LogDatabase : RoomDatabase() {
companion object {
const val LOGS_DATABASE_NAME = "rethink_logs.db"
private const val PRAGMA = "pragma wal_checkpoint(full)"
+ // cap the WAL file size (32MB). This is the high-volume database (connection and
+ // DNS logs); without a limit, the -wal file stays at its high-water mark forever
+ // after a large transaction (e.g. a bulk log purge), since SQLite reuses WAL
+ // space after a checkpoint but never shrinks the file on its own.
+ private const val JOURNAL_SIZE_LIMIT_BYTES = 32 * 1024 * 1024
private const val TABLE_NAME_DNS_LOGS = "DnsLogs"
// previous table name for dns logs
private const val TABLE_NAME_PREVIOUS_DNS = "DNSLogs"
@@ -92,8 +97,28 @@ abstract class LogDatabase : RoomDatabase() {
if (db.version > 5) return
populateDatabase(db)
}
+
+ override fun onOpen(db: SupportSQLiteDatabase) {
+ super.onOpen(db)
+ setJournalSizeLimit(db)
+ }
}
+ // PRAGMA journal_size_limit sets *and* returns the new limit, i.e. it is a
+ // result-returning pragma; SQLiteDatabase.execSQL() rejects such statements
+ // ("Queries can be performed using SQLiteDatabase query or rawQuery methods
+ // only"), so it must run through the query path with the cursor drained.
+ private fun setJournalSizeLimit(db: SupportSQLiteDatabase) {
+ try {
+ db.query(
+ SimpleSQLiteQuery("PRAGMA journal_size_limit = $JOURNAL_SIZE_LIMIT_BYTES")
+ ).use { it.moveToFirst() }
+ } catch (e: Exception) {
+ // non-fatal: without the limit the WAL simply keeps its high-water mark
+ Logger.w(LOG_TAG_APP_DB, "err setting journal_size_limit: ${e.message}", e)
+ }
+ }
+
private fun populateDatabase(db: SupportSQLiteDatabase) {
try {
db.execSQL(
@@ -359,7 +384,6 @@ abstract class LogDatabase : RoomDatabase() {
db.execSQL("CREATE INDEX IF NOT EXISTS index_Events_eventType ON Events(eventType)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_Events_severity ON Events(severity)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_Events_source ON Events(source)")
- db.execSQL("ALTER TABLE DnsLogs ADD COLUMN blockedTarget TEXT NOT NULL DEFAULT ''")
Logger.i(LOG_TAG_APP_DB, "MIGRATION_12_13: created Events table with indices")
} catch (e: Exception) {
diff --git a/app/src/main/java/com/celzero/bravedns/database/RefreshDatabase.kt b/app/src/main/java/com/celzero/bravedns/database/RefreshDatabase.kt
index b4605a86a9..fd5bfc49b5 100644
--- a/app/src/main/java/com/celzero/bravedns/database/RefreshDatabase.kt
+++ b/app/src/main/java/com/celzero/bravedns/database/RefreshDatabase.kt
@@ -105,7 +105,13 @@ internal constructor(
init {
io("RefreshDatabase") {
for (action in actions) {
- process(action)
+ try {
+ process(action)
+ } catch (e: Exception) {
+ // an uncaught exception here crashes the app and all subsequent refresh
+ // actions would be dropped.
+ Logger.crash(LOG_TAG_APP_DB, "refresh action failed, action: $action", e)
+ }
}
}
}
@@ -213,7 +219,7 @@ internal constructor(
printAll(packagesToDelete, "packagesToDelete")
printAll(packagesToUpdate, "packagesToUpdate")
- logEvent(Severity.LOW, "app refresh details", "sizes: rmv: ${packagesToDelete.size}; add: ${packagesToAdd.size}; update: ${packagesToUpdate.size}, tombstone: ${packagesToTombstone.size}, action: $action, tombstoneEnabled? $canTombstone")
+ logEvent(Severity.LOW, "app refresh details", "sizes: rmv: ${packagesToDelete.size}; add: ${packagesToAdd.size} [$packagesToAdd]; update: ${packagesToUpdate.size} [$packagesToUpdate], tombstone: ${packagesToTombstone.size} [$packagesToTombstone], action: $action, tombstoneEnabled? $canTombstone")
Logger.i(
LOG_TAG_APP_DB,
"sizes: rmv: ${packagesToDelete.size}; add: ${packagesToAdd.size}; update: ${packagesToUpdate.size}, tombstone: ${packagesToTombstone.size}, action: $action, tombstoneEnabled? $canTombstone"
@@ -374,25 +380,33 @@ internal constructor(
installedApps: Set
) {
// if a non-app appears installed-apps group, then upsert its db entry
- // and give it a proper identity as retrieved from the package-manager
val nonApps = trackedApps.filter { isNonApp(it.packageName) }.map { it.uid }.toSet()
- installedApps.forEach { x ->
- if (nonApps.contains(x.uid)) {
- val prevPackageName =
- trackedApps.filter { i -> i.uid == x.uid }.map { it.packageName }
- upsertNonApp(x, prevPackageName.firstOrNull())
+ installedApps.filter { nonApps.contains(it.uid) }
+ .groupBy { it.uid }
+ .forEach { (uid, installed) ->
+ // only the placeholder (no_package_) must be replaced
+ val placeholder =
+ trackedApps.firstOrNull { it.uid == uid && isNonApp(it.packageName) }?.packageName
+ upsertNonApp(uid, installed, placeholder)
}
- }
}
private suspend fun upsertNonApp(
- appTuple: FirewallManager.AppInfoTuple,
- prevPackageName: String?
+ uid: Int,
+ installed: List,
+ placeholderPackageName: String?
) {
- val appInfo = fetchApplicationInfo(appTuple.uid) ?: return
// TODO: implement upsert logic handling all the edge cases
- deletePackage(appTuple.uid, prevPackageName)
- insertApp(appInfo)
+ if (placeholderPackageName != null) {
+ deletePackage(uid, placeholderPackageName)
+ }
+ // insert every installed package sharing this uid; skip ones already tracked
+ installed.forEach { x ->
+ val known = FirewallManager.getAppInfoByUidAndPackage(x.uid, x.packageName)
+ if (known != null) return@forEach
+ val ai = Utilities.getApplicationInfo(ctx, x.packageName) ?: return@forEach
+ insertApp(ai)
+ }
}
private suspend fun addMissingPackages(apps: Set) {
@@ -632,7 +646,8 @@ internal constructor(
newAppInfo.appCategory = ctx.getString(FirewallManager.CategoryConstants.NON_APP.nameResId)
newAppInfo.uid = uid
- if (persistentState.getBlockNewlyInstalledApp()) {
+ val isSystemComponent = newAppInfo.isSystemApp && !AndroidUidConfig.isUidAppRange(uid)
+ if (persistentState.getBlockNewlyInstalledApp() && !isSystemComponent) {
newAppInfo.firewallStatus = FirewallManager.FirewallStatus.NONE.id
newAppInfo.connectionStatus = FirewallManager.ConnectionStatus.BOTH.id
}
@@ -657,6 +672,7 @@ internal constructor(
ctx.getString(R.string.network_log_app_name_unnamed, ai.uid.toString())
}
val isSystemApp = isSystemApp(ai)
+ val isSystemComponent = isSystemComponent(ai)
val entry = AppInfo(null)
entry.appName = appName
@@ -668,7 +684,8 @@ internal constructor(
entry.isSystemApp = isSystemApp
// do not firewall app by default, if blockNewlyInstalledApp is set to false
- if (persistentState.getBlockNewlyInstalledApp()) {
+ // skip blocking of system components
+ if (persistentState.getBlockNewlyInstalledApp() && !isSystemComponent) {
entry.firewallStatus = FirewallManager.FirewallStatus.NONE.id
entry.connectionStatus = FirewallManager.ConnectionStatus.BOTH.id
} else {
diff --git a/app/src/main/java/com/celzero/bravedns/database/RethinkLocalFileTagRepository.kt b/app/src/main/java/com/celzero/bravedns/database/RethinkLocalFileTagRepository.kt
index 1eff40b40d..a5d630307c 100644
--- a/app/src/main/java/com/celzero/bravedns/database/RethinkLocalFileTagRepository.kt
+++ b/app/src/main/java/com/celzero/bravedns/database/RethinkLocalFileTagRepository.kt
@@ -27,10 +27,6 @@ class RethinkLocalFileTagRepository(private val rethinkLocalFileTagDao: RethinkL
rethinkLocalFileTagDao.update(fileTag)
}
- fun contentUpdate(fileTag: RethinkLocalFileTag): Int {
- return rethinkLocalFileTagDao.update(fileTag)
- }
-
fun contentInsert(fileTag: RethinkLocalFileTag): Long {
return rethinkLocalFileTagDao.insert(fileTag)
}
diff --git a/app/src/main/java/com/celzero/bravedns/database/RethinkLogDao.kt b/app/src/main/java/com/celzero/bravedns/database/RethinkLogDao.kt
index de67087b03..cf98218d5e 100644
--- a/app/src/main/java/com/celzero/bravedns/database/RethinkLogDao.kt
+++ b/app/src/main/java/com/celzero/bravedns/database/RethinkLogDao.kt
@@ -37,6 +37,44 @@ interface RethinkLogDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertBatch(logs: List)
+ // bucket aggregation for the activity wall (LogActivityAggregator);
+ // bucketIndex = (timeStamp - rangeStart) / bucketMs, grouped per blocked
+ // classification. Pass bucketMs=600000 (10 min) for the trailing-24h
+ // ten-minute wall slots.
+ @Query(
+ "select cast((timeStamp - :rangeStart)/:bucketMs as integer) as bucketIndex, isBlocked as blocked, count(id) as total from RethinkLog where timeStamp >= :rangeStart and timeStamp < :rangeEnd group by bucketIndex, blocked"
+ )
+ suspend fun getActivityBuckets(
+ rangeStart: Long,
+ rangeEnd: Long,
+ bucketMs: Long
+ ): List
+
+ @Query(
+ "select coalesce(sum(case when isBlocked then 1 else 0 end), 0) as blocked, count(*) as total from RethinkLog where timeStamp >= :start and timeStamp < :end"
+ )
+ suspend fun getWindowCounts(start: Long, end: Long): WindowCountRow
+
+ @Query(
+ "select * from RethinkLog where timeStamp >= :start and timeStamp < :end order by id desc limit :limit"
+ )
+ suspend fun getRethinkLogsInWindow(start: Long, end: Long, limit: Int): List
+
+ @Query(
+ "select uid as uid, appName as appName, count(id) as total, sum(case when isBlocked then 1 else 0 end) as blocked from RethinkLog where timeStamp >= :start and timeStamp < :end group by uid, appName order by total desc limit :limit"
+ )
+ suspend fun getAppActivity(start: Long, end: Long, limit: Int): List
+
+ @Query(
+ "select * from RethinkLog where timeStamp >= :start and timeStamp < :end and uid = :uid order by id desc limit :limit"
+ )
+ suspend fun getRethinkLogsInWindowForUid(
+ start: Long,
+ end: Long,
+ uid: Int,
+ limit: Int
+ ): List
+
@Query(
"update RethinkLog set proxyDetails = :pid, rpid = :rpid, downloadBytes = :downloadBytes, uploadBytes = :uploadBytes, duration = :duration, synack = :synack, message = :message where connId = :connId"
)
diff --git a/app/src/main/java/com/celzero/bravedns/database/RethinkLogRepository.kt b/app/src/main/java/com/celzero/bravedns/database/RethinkLogRepository.kt
index 5605836917..48430a77e7 100644
--- a/app/src/main/java/com/celzero/bravedns/database/RethinkLogRepository.kt
+++ b/app/src/main/java/com/celzero/bravedns/database/RethinkLogRepository.kt
@@ -63,4 +63,33 @@ class RethinkLogRepository(private val logDao: RethinkLogDao) {
fun getDataUsage(from: Long, to: Long): DataUsage? {
return logDao.getDataUsage(from, to)
}
+
+ suspend fun getActivityBuckets(
+ rangeStart: Long,
+ rangeEnd: Long,
+ bucketMs: Long
+ ): List {
+ return logDao.getActivityBuckets(rangeStart, rangeEnd, bucketMs)
+ }
+
+ suspend fun getWindowCounts(start: Long, end: Long): WindowCountRow {
+ return logDao.getWindowCounts(start, end)
+ }
+
+ suspend fun getRethinkLogsInWindow(start: Long, end: Long, limit: Int): List {
+ return logDao.getRethinkLogsInWindow(start, end, limit)
+ }
+
+ suspend fun getAppActivity(start: Long, end: Long, limit: Int): List {
+ return logDao.getAppActivity(start, end, limit)
+ }
+
+ suspend fun getRethinkLogsInWindowForUid(
+ start: Long,
+ end: Long,
+ uid: Int,
+ limit: Int
+ ): List {
+ return logDao.getRethinkLogsInWindowForUid(start, end, uid, limit)
+ }
}
diff --git a/app/src/main/java/com/celzero/bravedns/database/SmartDnsEndpoint.kt b/app/src/main/java/com/celzero/bravedns/database/SmartDnsEndpoint.kt
new file mode 100644
index 0000000000..0bb9741708
--- /dev/null
+++ b/app/src/main/java/com/celzero/bravedns/database/SmartDnsEndpoint.kt
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2026 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.database
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import com.celzero.firestack.settings.Settings
+
+@Entity(tableName = "SmartDnsEndpoint")
+data class SmartDnsEndpoint(
+ @PrimaryKey(autoGenerate = true) val id: Int = 0,
+ val dnsName: String,
+ // filter mode of the smart dns endpoint, see SmartDnsMode
+ val dnsMode: Int,
+ val dnsExplanation: String,
+ var isSelected: Boolean = false,
+ val modifiedDataTime: Long = 0,
+ val latency: Int = 0
+) {
+ companion object {
+
+ fun isNoFilterMode(mode: Int): Boolean {
+ return mode == SmartDnsMode.NO_FILTER.mode
+ }
+
+ fun isSecurityMode(mode: Int): Boolean {
+ return mode == SmartDnsMode.SECURITY.mode
+ }
+
+ fun isFamilyMode(mode: Int): Boolean {
+ return mode == SmartDnsMode.FAMILY.mode
+ }
+ }
+}
+
+// supported smart dns filter modes, seeded in AppDatabase.MIGRATION_33_34
+enum class SmartDnsMode(val mode: Int) {
+ NO_FILTER(0),
+ SECURITY(1),
+ PRIVACY(2),
+ FAMILY(3);
+
+ companion object {
+ fun getMode(id: Int): SmartDnsMode {
+ return when (id) {
+ NO_FILTER.mode -> NO_FILTER
+ SECURITY.mode -> SECURITY
+ PRIVACY.mode -> PRIVACY
+ FAMILY.mode -> FAMILY
+ else -> NO_FILTER
+ }
+ }
+
+ fun getTunMode(id: Int): Long {
+ return when (id) {
+ NO_FILTER.mode -> Settings.PlusFilterNone
+ SECURITY.mode -> Settings.PlusFilterSecurity
+ FAMILY.mode -> Settings.PlusFilterFamily
+ PRIVACY.mode -> Settings.PlusFilterAdblock
+ else -> Settings.PlusFilterNone
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/celzero/bravedns/database/SmartDnsEndpointDAO.kt b/app/src/main/java/com/celzero/bravedns/database/SmartDnsEndpointDAO.kt
new file mode 100644
index 0000000000..cb7f37ffa0
--- /dev/null
+++ b/app/src/main/java/com/celzero/bravedns/database/SmartDnsEndpointDAO.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2026 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.database
+
+import androidx.lifecycle.LiveData
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+
+@Dao
+interface SmartDnsEndpointDAO {
+
+ @Query("SELECT * FROM SmartDnsEndpoint ORDER BY dnsMode ASC")
+ suspend fun getSmartDnsEndpoints(): List
+
+ @Query("SELECT * FROM SmartDnsEndpoint ORDER BY dnsMode ASC")
+ fun getSmartDnsEndpointsLiveData(): LiveData>
+
+ @Query("SELECT * FROM SmartDnsEndpoint WHERE isSelected = 1 LIMIT 1")
+ suspend fun getSelectedEndpoint(): SmartDnsEndpoint?
+
+ @Query("SELECT COUNT(*) FROM SmartDnsEndpoint")
+ suspend fun getCount(): Int
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insert(endpoint: SmartDnsEndpoint)
+
+ @Query("UPDATE SmartDnsEndpoint SET isSelected = 0 WHERE isSelected = 1")
+ suspend fun removeConnectionStatus()
+
+ @Query("UPDATE SmartDnsEndpoint SET isSelected = 1 WHERE id = :id")
+ suspend fun setConnectionStatus(id: Int)
+}
diff --git a/app/src/main/java/com/celzero/bravedns/database/SmartDnsEndpointRepository.kt b/app/src/main/java/com/celzero/bravedns/database/SmartDnsEndpointRepository.kt
new file mode 100644
index 0000000000..fb78af7377
--- /dev/null
+++ b/app/src/main/java/com/celzero/bravedns/database/SmartDnsEndpointRepository.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2026 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.database
+
+import androidx.room.Transaction
+import com.celzero.bravedns.util.Logger
+import com.celzero.bravedns.util.Logger.LOG_TAG_DNS
+
+class SmartDnsEndpointRepository(private val smartDnsEndpointDAO: SmartDnsEndpointDAO) {
+
+ companion object {
+ private const val TAG = "SmartDnsEndpointRepo"
+ }
+
+ suspend fun getSmartDnsEndpoints(): List {
+ return smartDnsEndpointDAO.getSmartDnsEndpoints()
+ }
+
+ suspend fun getSelectedEndpoint(): SmartDnsEndpoint? {
+ return smartDnsEndpointDAO.getSelectedEndpoint()
+ }
+
+ suspend fun getCount(): Int {
+ return smartDnsEndpointDAO.getCount()
+ }
+
+ suspend fun insert(endpoint: SmartDnsEndpoint) {
+ smartDnsEndpointDAO.insert(endpoint)
+ }
+
+ @Transaction
+ suspend fun select(id: Int) {
+ Logger.i(LOG_TAG_DNS, "$TAG select smart dns endpoint: $id")
+ smartDnsEndpointDAO.removeConnectionStatus()
+ smartDnsEndpointDAO.setConnectionStatus(id)
+ }
+
+ suspend fun removeConnectionStatus() {
+ smartDnsEndpointDAO.removeConnectionStatus()
+ }
+}
diff --git a/app/src/main/java/com/celzero/bravedns/database/StatsSummaryDao.kt b/app/src/main/java/com/celzero/bravedns/database/StatsSummaryDao.kt
index 41e6c88bdc..e46ef7165d 100644
--- a/app/src/main/java/com/celzero/bravedns/database/StatsSummaryDao.kt
+++ b/app/src/main/java/com/celzero/bravedns/database/StatsSummaryDao.kt
@@ -419,7 +419,7 @@ interface StatsSummaryDao {
0 AS downloadBytes,
0 AS uploadBytes
FROM DnsLogs
- WHERE isBlocked = 0
+ WHERE isBlocked = 0
AND status = 'COMPLETE'
AND queryStr != ''
AND time > :to
@@ -434,7 +434,7 @@ interface StatsSummaryDao {
sum(downloadBytes) as downloadBytes,
sum(uploadBytes) as uploadBytes
FROM ConnectionTracker
- WHERE isBlocked = 0
+ WHERE isBlocked = 0
AND timeStamp > :to
AND dnsQuery != ''
GROUP BY uid
@@ -467,7 +467,7 @@ interface StatsSummaryDao {
0 as downloadBytes,
0 as uploadBytes
FROM DnsLogs
- WHERE isBlocked = 0
+ WHERE isBlocked = 0
AND status = 'COMPLETE'
AND queryStr != ''
AND time > :to
@@ -482,7 +482,7 @@ interface StatsSummaryDao {
sum(downloadBytes) as downloadBytes,
sum(uploadBytes) as uploadBytes
FROM ConnectionTracker
- WHERE isBlocked = 0
+ WHERE isBlocked = 0
AND timeStamp > :to
AND dnsQuery != ''
GROUP BY uid
@@ -519,7 +519,7 @@ interface StatsSummaryDao {
UNION ALL
- -- From ConnectionTracker
+ -- From ConnectionTracker
SELECT uid as uid,
appName AS appOrDnsName,
COUNT(id) AS count
@@ -610,6 +610,7 @@ interface StatsSummaryDao {
FROM ConnectionTracker
WHERE isBlocked = 1
AND timeStamp > :to
+ AND dnsQuery != ''
AND blockedByRule LIKE 'Rule #2G%'
GROUP BY dnsQuery
) AS combined
@@ -637,24 +638,25 @@ interface StatsSummaryDao {
-- From DnsLogs
SELECT RTRIM(queryStr, '.') AS appOrDnsName,
COUNT(id) AS count,
- flag
- FROM DnsLogs
+ flag
+ FROM DnsLogs
WHERE isBlocked = 1
- AND time > :to
+ AND time > :to
AND queryStr != ''
GROUP BY RTRIM(queryStr, '.')
- UNION ALL
+ UNION ALL
-- From ConnectionTracker
SELECT dnsQuery AS appOrDnsName,
COUNT(id) AS count,
- flag
+ flag
FROM ConnectionTracker
- WHERE isBlocked = 1
+ WHERE isBlocked = 1
AND timeStamp > :to
+ AND dnsQuery != ''
AND blockedByRule LIKE 'Rule #2G%'
- GROUP BY
+ GROUP BY
dnsQuery
) AS combined
GROUP BY appOrDnsName
@@ -681,7 +683,7 @@ interface StatsSummaryDao {
COUNT(id) AS count,
flag
FROM DnsLogs
- WHERE isBlocked = 0
+ WHERE isBlocked = 0
AND status = 'COMPLETE'
AND queryStr != ''
AND time > :to
@@ -694,7 +696,7 @@ interface StatsSummaryDao {
COUNT(id) AS count,
flag
FROM ConnectionTracker
- WHERE isBlocked = 0
+ WHERE isBlocked = 0
AND timeStamp > :to
AND dnsQuery != ''
GROUP BY dnsQuery
@@ -723,8 +725,8 @@ interface StatsSummaryDao {
SELECT RTRIM(queryStr, '.') AS appOrDnsName,
COUNT(id) AS count,
flag
- FROM DnsLogs
- WHERE isBlocked = 0
+ FROM DnsLogs
+ WHERE isBlocked = 0
AND status = 'COMPLETE'
AND queryStr != ''
AND time > :to
@@ -737,7 +739,7 @@ interface StatsSummaryDao {
COUNT(id) AS count,
flag
FROM ConnectionTracker
- WHERE isBlocked = 0
+ WHERE isBlocked = 0
AND timeStamp > :to
AND dnsQuery != ''
GROUP BY dnsQuery
@@ -764,27 +766,33 @@ interface StatsSummaryDao {
(
-- From DnsLogs
SELECT COUNT(id) AS count,
- flag
- FROM DnsLogs
- WHERE isBlocked = 0
- AND status = 'COMPLETE'
- AND queryStr != ''
- AND time > :to
- GROUP BY flag
+ flag
+ FROM DnsLogs
+ WHERE isBlocked = 0
+ AND status = 'COMPLETE'
+ AND queryStr != ''
+ AND time > :to
+ AND flag != ''
+ GROUP BY flag
- UNION ALL
-
- -- From ConnectionTracker
- SELECT COUNT(id) AS count,
- flag
- FROM ConnectionTracker
- WHERE isBlocked = 0
- AND timeStamp > :to
- GROUP BY flag
- ) AS combined
- GROUP BY flag
- ORDER BY count DESC
- LIMIT 7
+ UNION ALL
+
+ -- From ConnectionTracker
+ SELECT COUNT(id) AS count,
+ flag
+ FROM ConnectionTracker
+ WHERE isBlocked = 0
+ AND timeStamp > :to
+ AND flag != ''
+ GROUP BY flag
+ ) AS combined
+ -- keep only valid flag emojis (U+1F1E6..U+1F1FF pairs, e.g. 'AA'..'ZZ'
+ -- in regional indicators). Excludes placeholders written by log trackers:
+ -- '?', warning sign and the invalid pair derived from CountryMap's "--" unknown marker.
+ WHERE flag BETWEEN char(127462, 127462) AND char(127487, 127487)
+ GROUP BY flag
+ ORDER BY count DESC
+ LIMIT 7
"""
)
fun getMostContactedCountries(to: Long): PagingSource
@@ -805,22 +813,22 @@ interface StatsSummaryDao {
(
-- From DnsLogs
SELECT COUNT(id) AS count,
- flag
- FROM DnsLogs
- WHERE isBlocked = 0
- AND status = 'COMPLETE'
- AND queryStr != ''
- AND time > :to
+ flag
+ FROM DnsLogs
+ WHERE isBlocked = 0
+ AND status = 'COMPLETE'
+ AND queryStr != ''
+ AND time > :to
+ AND flag != ''
GROUP BY flag
-
- UNION ALL
-
+ UNION ALL
-- From ConnectionTracker
SELECT COUNT(id) AS count,
flag
FROM ConnectionTracker
WHERE isBlocked = 0
AND timeStamp > :to
+ AND flag != ''
GROUP BY flag
) AS combined
GROUP BY flag
@@ -856,23 +864,23 @@ interface StatsSummaryDao {
AND isBlocked = :isBlocked
GROUP BY uid
- UNION ALL
+ UNION ALL
-- From DnsLogs
- SELECT uid,
+ SELECT uid,
appName AS appOrDnsName,
- COUNT(id) AS count,
- 0 AS uploadBytes,
- 0 AS downloadBytes
- FROM DnsLogs
- WHERE time > :to
- AND queryStr like :query
+ COUNT(id) AS count,
+ 0 AS uploadBytes,
+ 0 AS downloadBytes
+ FROM DnsLogs
+ WHERE time > :to
+ AND queryStr like :query
AND isBlocked = :isBlocked
- AND status = 'COMPLETE'
- AND queryStr != ''
+ AND status = 'COMPLETE'
+ AND queryStr != ''
GROUP BY uid
- ) AS combined
- GROUP BY uid
+ ) AS combined
+ GROUP BY uid
ORDER BY count DESC
"""
)
@@ -909,17 +917,17 @@ interface StatsSummaryDao {
UNION ALL
-- From DnsLogs
- SELECT uid,
+ SELECT uid,
appName AS appOrDnsName,
COUNT(id) AS count,
- flag as flag,
- 0 AS uploadBytes,
- 0 AS downloadBytes
- FROM DnsLogs
- WHERE time > :to
- AND flag = :flag
- AND isBlocked = 0
- AND status = 'COMPLETE'
+ flag as flag,
+ 0 AS uploadBytes,
+ 0 AS downloadBytes
+ FROM DnsLogs
+ WHERE time > :to
+ AND flag = :flag
+ AND isBlocked = 0
+ AND status = 'COMPLETE'
AND queryStr != ''
GROUP BY uid
) AS combined
@@ -952,17 +960,17 @@ interface StatsSummaryDao {
AND timeStamp > :to
AND uid = :uid
GROUP BY dnsQuery
-
- UNION ALL
+
+ UNION ALL
-- From DnsLogs
SELECT queryStr AS appOrDnsName,
COUNT(queryStr) AS count,
flag as flag
- FROM DnsLogs
+ FROM DnsLogs
WHERE uid = :uid
- AND time > :to
- AND status = 'COMPLETE'
+ AND time > :to
+ AND status = 'COMPLETE'
AND queryStr != ''
GROUP BY queryStr
) AS combined
@@ -992,22 +1000,20 @@ interface StatsSummaryDao {
COUNT(dnsQuery) AS count,
flag as flag
FROM ConnectionTracker
- WHERE dnsQuery != ''
+ WHERE dnsQuery != ''
AND timeStamp > :to
AND uid = :uid
AND dnsQuery LIKE :input
GROUP BY dnsQuery
-
- UNION ALL
-
+ UNION ALL
-- From DnsLogs
SELECT queryStr AS appOrDnsName,
COUNT(queryStr) AS count,
flag as flag
- FROM DnsLogs
+ FROM DnsLogs
WHERE uid = :uid
- AND time > :to
- AND status = 'COMPLETE'
+ AND time > :to
+ AND status = 'COMPLETE'
AND queryStr != ''
AND queryStr LIKE :input
GROUP BY queryStr
@@ -1041,17 +1047,15 @@ interface StatsSummaryDao {
AND timeStamp > :to
AND uid = :uid
GROUP BY dnsQuery
-
- UNION ALL
-
+ UNION ALL
-- From DnsLogs
SELECT queryStr AS appOrDnsName,
COUNT(queryStr) AS count,
flag as flag
- FROM DnsLogs
+ FROM DnsLogs
WHERE uid = :uid
- AND time > :to
- AND status = 'COMPLETE'
+ AND time > :to
+ AND status = 'COMPLETE'
AND queryStr != ''
GROUP BY queryStr
) AS combined
diff --git a/app/src/main/java/com/celzero/bravedns/database/SubscriptionStateHistoryDao.kt b/app/src/main/java/com/celzero/bravedns/database/SubscriptionStateHistoryDao.kt
index fb8a1e0593..43b803d2c2 100644
--- a/app/src/main/java/com/celzero/bravedns/database/SubscriptionStateHistoryDao.kt
+++ b/app/src/main/java/com/celzero/bravedns/database/SubscriptionStateHistoryDao.kt
@@ -62,4 +62,7 @@ interface SubscriptionStateHistoryDao {
""")
suspend fun getMeaningfulCount(): Int
+ @Query("DELETE FROM SubscriptionStateHistory")
+ suspend fun deleteAll()
+
}
diff --git a/app/src/main/java/com/celzero/bravedns/database/SubscriptionStatusDao.kt b/app/src/main/java/com/celzero/bravedns/database/SubscriptionStatusDao.kt
index 4c999dffdf..af1077be45 100644
--- a/app/src/main/java/com/celzero/bravedns/database/SubscriptionStatusDao.kt
+++ b/app/src/main/java/com/celzero/bravedns/database/SubscriptionStatusDao.kt
@@ -79,7 +79,7 @@ interface SubscriptionStatusDao {
@Query("""
SELECT * FROM SubscriptionStatus
WHERE status IN (1, 2, 5, 6, 9, 10, 11)
- ORDER BY lastUpdatedTs DESC
+ ORDER BY CASE WHEN status = 1 THEN 0 ELSE 1 END, lastUpdatedTs DESC
LIMIT 1
""")
suspend fun getCurrentValidSubscription(): SubscriptionStatus?
@@ -132,7 +132,11 @@ interface SubscriptionStatusDao {
suspend fun markExpiredSubscriptions(currentTime: Long): Int
// Reactive queries with Flow
- @Query("SELECT * FROM SubscriptionStatus ORDER BY lastUpdatedTs DESC LIMIT 1")
+ @Query("""
+ SELECT * FROM SubscriptionStatus
+ ORDER BY CASE WHEN status = 1 THEN 0 ELSE 1 END, lastUpdatedTs DESC
+ LIMIT 1
+ """)
fun observeCurrentSubscription(): Flow
@Query("SELECT * FROM SubscriptionStatus ORDER BY lastUpdatedTs DESC")
diff --git a/app/src/main/java/com/celzero/bravedns/download/AppDownloadManager.kt b/app/src/main/java/com/celzero/bravedns/download/AppDownloadManager.kt
index 7eb2bfce4f..b231cee54d 100644
--- a/app/src/main/java/com/celzero/bravedns/download/AppDownloadManager.kt
+++ b/app/src/main/java/com/celzero/bravedns/download/AppDownloadManager.kt
@@ -30,6 +30,7 @@ import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkRequest
import androidx.work.workDataOf
+import com.celzero.bravedns.R
import com.celzero.bravedns.customdownloader.LocalBlocklistCoordinator
import com.celzero.bravedns.customdownloader.RemoteBlocklistCoordinator
import com.celzero.bravedns.download.BlocklistDownloadHelper.Companion.checkBlocklistUpdate
@@ -44,6 +45,7 @@ import com.celzero.bravedns.util.Constants.Companion.ONDEVICE_BLOCKLISTS_ADM
import com.celzero.bravedns.util.Utilities
import com.celzero.bravedns.util.Utilities.hasLocalBlocklists
import java.util.concurrent.TimeUnit
+import androidx.core.net.toUri
/**
* Generic class responsible for downloading the block list for both remote and local. As of now,
@@ -57,11 +59,152 @@ class AppDownloadManager(
) {
private lateinit var downloadManager: DownloadManager
+ private val workManager = WorkManager.getInstance(context)
// live data to initiate the download, contains time stamp if the download is required,
// else will have
val downloadRequired: MutableLiveData = MutableLiveData()
+ sealed interface DownloadState {
+ data object Idle : DownloadState
+ data object Starting : DownloadState
+ data class Downloading(val progress: Int) : DownloadState
+ data object Processing : DownloadState
+ data object Success : DownloadState
+ data class Error(val reason: String) : DownloadState
+ }
+
+ val downloadState: MutableLiveData = MutableLiveData(DownloadState.Idle)
+
+ fun resetDownloadState() {
+ downloadState.postValue(DownloadState.Idle)
+ persistentState.lastDownloadFailureReason = ""
+ }
+
+
+ fun isDownloadWorkActive(type: DownloadType): Boolean {
+ return if (type.isLocal()) {
+ WorkScheduler.isWorkScheduled(context, DOWNLOAD_TAG) ||
+ WorkScheduler.isWorkScheduled(context, FILE_TAG) ||
+ WorkScheduler.isWorkScheduled(context, LocalBlocklistCoordinator.CUSTOM_DOWNLOAD) ||
+ WorkScheduler.isWorkRunning(context, LocalBlocklistCoordinator.CUSTOM_DOWNLOAD)
+ } else {
+ WorkScheduler.isWorkScheduled(context, RemoteBlocklistCoordinator.REMOTE_DOWNLOAD_WORKER) ||
+ WorkScheduler.isWorkRunning(context, RemoteBlocklistCoordinator.REMOTE_DOWNLOAD_WORKER)
+ }
+ }
+
+ /**
+ * The sticky [downloadState] can be left in an in progress state (Starting/Downloading/
+ * Processing) when the download finished while no screen observing [observeWorkManager]
+ * was alive eg, the download was started from LocalBlocklistsBottomSheet, whose own
+ * observers prune the finished WorkInfos. The terminal (Success/Error) transition is
+ * then lost forever and every later screen misreads the stale value as "download in
+ * progress". Reconcile: if the sticky state claims a download is in flight but no
+ * download work actually is, reset to Idle.
+ *
+ * Blocking WorkManager queries: must not be called on the main thread.
+ */
+ fun reconcileStaleInFlightDownloadState(type: DownloadType) {
+ val state = downloadState.value ?: return
+ val inFlight = state is DownloadState.Starting ||
+ state is DownloadState.Downloading ||
+ state is DownloadState.Processing
+ if (!inFlight) return
+ if (isDownloadWorkActive(type)) return
+
+ Logger.i(
+ LOG_TAG_DOWNLOAD,
+ "reconciling stale in-flight download state: $state, no download work active for ${type.name}"
+ )
+ resetDownloadState()
+ }
+
+ private fun mapWorkInfoToDownloadState(tag: String, workInfo: androidx.work.WorkInfo): DownloadState {
+ return when (workInfo.state) {
+ androidx.work.WorkInfo.State.ENQUEUED, androidx.work.WorkInfo.State.RUNNING -> {
+ val progress = workInfo.progress.getInt("progress", -1)
+ val processing = workInfo.progress.getBoolean("processing", false)
+ if (processing || tag == FILE_TAG) {
+ DownloadState.Processing
+ } else if (progress >= 0) {
+ DownloadState.Downloading(progress)
+ } else {
+ DownloadState.Starting
+ }
+ }
+ androidx.work.WorkInfo.State.SUCCEEDED -> {
+ if (tag == FILE_TAG || tag == LocalBlocklistCoordinator.CUSTOM_DOWNLOAD || tag == RemoteBlocklistCoordinator.REMOTE_DOWNLOAD_WORKER) {
+ DownloadState.Success
+ } else {
+ // DOWNLOAD_TAG succeeded, wait for FILE_TAG
+ DownloadState.Processing
+ }
+ }
+ androidx.work.WorkInfo.State.FAILED, androidx.work.WorkInfo.State.CANCELLED -> {
+ val reason = persistentState.lastDownloadFailureReason
+ DownloadState.Error(reason.ifEmpty { context.getString(R.string.download_err_internal) })
+ }
+ else -> DownloadState.Idle
+ }
+ }
+
+ fun observeWorkManager(lifecycleOwner: androidx.lifecycle.LifecycleOwner) {
+ val tags = listOf(DOWNLOAD_TAG, FILE_TAG, LocalBlocklistCoordinator.CUSTOM_DOWNLOAD, RemoteBlocklistCoordinator.REMOTE_DOWNLOAD_WORKER)
+ tags.forEach { tag ->
+ workManager.getWorkInfosByTagLiveData(tag).observe(lifecycleOwner) { workInfoList ->
+ // Finished WorkInfos linger in WorkManager until pruned; they must not
+ // shadow a newer attempt for the same tag (a stale FAILED entry would
+ // replay the error dialog over a live download and, once current, block
+ // any further transition via shouldUpdateState). Prefer an in-flight info.
+ val active = workInfoList?.firstOrNull {
+ it.state == androidx.work.WorkInfo.State.ENQUEUED ||
+ it.state == androidx.work.WorkInfo.State.RUNNING ||
+ it.state == androidx.work.WorkInfo.State.BLOCKED
+ }
+ val workInfo = active ?: workInfoList?.lastOrNull() ?: return@observe
+ val newState = mapWorkInfoToDownloadState(tag, workInfo)
+ val currentState = downloadState.value
+ if (shouldUpdateState(currentState, newState)) {
+ downloadState.postValue(newState)
+ if (newState is DownloadState.Error || newState is DownloadState.Success) {
+ // Drop finished entries (incl. stale ones) so terminal states
+ // cannot be re-delivered as duplicate/spurious dialogs later.
+ workManager.pruneWork()
+ }
+ }
+ }
+ }
+ }
+
+ private fun shouldUpdateState(current: DownloadState?, next: DownloadState): Boolean {
+ if (current == null) return true
+ // Terminal states are final: a later WorkManager tag callback (e.g. a finished
+ // WorkInfo observed before pruneWork() takes effect) must not flip Success ->
+ // Error or Error -> Success, which would trigger the wrong dialog or toast.
+ // Reject any transition out of a terminal state before evaluating next.
+ if (current is DownloadState.Error || current is DownloadState.Success) return false
+ // Only transition *into* Error/Success once. This avoids re-triggering the error
+ // dialog (or success toast) when multiple WorkManager tag-observers each report
+ // the same terminal state.
+ if (next is DownloadState.Error) return true
+ if (next is DownloadState.Success) return true
+
+ return when (current) {
+ is DownloadState.Idle -> next !is DownloadState.Idle
+ is DownloadState.Starting -> next !is DownloadState.Starting && next !is DownloadState.Idle
+ is DownloadState.Downloading -> {
+ if (next is DownloadState.Downloading) {
+ next.progress >= current.progress
+ } else {
+ next is DownloadState.Processing
+ }
+ }
+ is DownloadState.Processing -> next is DownloadState.Processing
+ else -> false
+ }
+ }
+
// various download status used as part of Work manager.
enum class DownloadManagerStatus(val id: Int) {
NOT_AVAILABLE(STATUS_NOT_AVAILABLE),
@@ -149,22 +292,29 @@ class AppDownloadManager(
}
private fun cancelAndroidDownloadManagerDownloads() {
- try {
- val downloadIdsStr = persistentState.androidDownloadManagerIds
- if (downloadIdsStr.isEmpty()) {
- Logger.i(LOG_TAG_DOWNLOAD, "no andr-down-mgr downloads to cancel")
- return
- }
-
- val downloadIds = downloadIdsStr.split(",").mapNotNull { it.toLongOrNull() }
- if (downloadIds.isEmpty()) {
- Logger.i(LOG_TAG_DOWNLOAD, "no valid download IDs found to cancel")
- return
- }
+ val idsStr = persistentState.androidDownloadManagerIds
+ if (idsStr.isEmpty()) {
+ Logger.i(LOG_TAG_DOWNLOAD, "no andr-down-mgr downloads to cancel")
+ return
+ }
+ val ids = idsStr.split(",").mapNotNull { it.toLongOrNull() }
+ cancelAndroidDownloadManagerDownloads(ids)
+ persistentState.androidDownloadManagerIds = ""
+ }
+ /**
+ * Cancels a specific set of Android Download Manager downloads (e.g. partially-enqueued
+ * orphans) without touching [PersistentState.androidDownloadManagerIds].
+ */
+ private fun cancelAndroidDownloadManagerDownloads(ids: List) {
+ if (ids.isEmpty()) {
+ Logger.i(LOG_TAG_DOWNLOAD, "no valid download IDs provided to cancel")
+ return
+ }
+ try {
downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
var cancelledCount = 0
- downloadIds.forEach { downloadId ->
+ ids.forEach { downloadId ->
try {
val removed = downloadManager.remove(downloadId)
if (removed > 0) {
@@ -175,8 +325,6 @@ class AppDownloadManager(
Logger.w(LOG_TAG_DOWNLOAD, "failed to cancel download id: $downloadId", e)
}
}
-
- persistentState.androidDownloadManagerIds = ""
Logger.i(LOG_TAG_DOWNLOAD, "cancelled $cancelledCount andr-down-mgr downloads")
} catch (e: Exception) {
Logger.e(LOG_TAG_DOWNLOAD, "err cancelling andr-down-mgr downloads", e)
@@ -210,20 +358,25 @@ class AppDownloadManager(
*/
suspend fun downloadLocalBlocklist(
currentTs: Long,
- isRedownload: Boolean
+ isRedownload: Boolean,
+ forceInApp: Boolean = false
): DownloadManagerStatus {
// local blocklist available only in fdroid and website version
if (!Utilities.isWebsiteFlavour() && !Utilities.isFdroidFlavour()) {
+ val msg = context.getString(R.string.download_err_flavor_not_supported)
+ downloadState.postValue(DownloadState.Error(msg))
return DownloadManagerStatus.FAILURE
}
val response = checkBlocklistUpdate(currentTs, persistentState.appVersion, retryCount = 0, persistentState.routeRethinkInRethink)
// if received response for update is null
if (response == null) {
+ val msg = context.getString(R.string.download_err_network)
Logger.w(
LOG_TAG_DNS,
"local blocklist update check is null, ts: $currentTs, app version: ${persistentState.appVersion}"
)
+ downloadState.postValue(DownloadState.Error(msg))
return DownloadManagerStatus.FAILURE
}
@@ -236,12 +389,23 @@ class AppDownloadManager(
LOG_TAG_DNS,
"local blocklist update not required, current ts: $currentTs, updatable ts: $updatableTs"
)
+ downloadState.postValue(DownloadState.Idle)
return DownloadManagerStatus.NOT_REQUIRED
} else {
// no-op
}
- if (persistentState.useCustomDownloadManager) {
+ // A download is about to start; publish the Starting state only now (posting it
+ // earlier would make the Idle transitions above get rejected by shouldUpdateState,
+ // leaving the UI stuck on "Starting").
+ downloadState.postValue(DownloadState.Starting)
+ if (forceInApp || persistentState.useCustomDownloadManager) {
+ if (forceInApp) {
+ Logger.i(
+ LOG_TAG_DNS,
+ "vpn active; forcing local blocklist download with custom download mgr"
+ )
+ }
Logger.i(LOG_TAG_DNS, "initiating local blocklist download with custom download mgr")
return initiateCustomDownloadManager(updatableTs)
}
@@ -257,21 +421,50 @@ class AppDownloadManager(
WorkScheduler.isWorkScheduled(context, FILE_TAG)
) {
Logger.i(LOG_TAG_DNS, "local blocklist download is already in progress, returning")
+ // downloadState will be updated by observeWorkManager
return DownloadManagerStatus.FAILURE
}
+ // If a previous run left stale download IDs (e.g. the app/process died before the
+ // worker ran, or the device rebooted), those downloads are orphaned and will never be
+ // processed by FileHandleWorker. Cancel and clear them so we start clean.
+ if (persistentState.androidDownloadManagerIds.isNotEmpty()) {
+ Logger.w(
+ LOG_TAG_DNS,
+ "stale android-download-mgr ids found at start; clearing orphans"
+ )
+ cancelAndroidDownloadManagerDownloads()
+ }
+
Logger.i(LOG_TAG_DNS, "local blocklist download is not in progress, starting the download")
purge(context, timestamp, DownloadType.LOCAL)
val downloadIds = LongArray(ONDEVICE_BLOCKLISTS_ADM.count())
+ val enqueued = mutableListOf()
+ var enqueueFailed = false
ONDEVICE_BLOCKLISTS_ADM.forEachIndexed { i, it ->
val fileName = it.filename
// url: https://dl.rethinkdns.com/update/blocklists?tstamp=1696197375609&vcode=33
- Logger.d(LOG_TAG_DOWNLOAD, "v: ($timestamp), f: $fileName, u: $it.url")
- downloadIds[i] = enqueueDownload(it.url, fileName, timestamp.toString())
- if (downloadIds[i] == INVALID_DOWNLOAD_ID) {
- return DownloadManagerStatus.FAILURE
+ Logger.d(LOG_TAG_DOWNLOAD, "v: ($timestamp), f: $fileName, u: ${it.url}")
+ val id = enqueueDownload(it.url, fileName, timestamp.toString())
+ if (id == INVALID_DOWNLOAD_ID) {
+ enqueueFailed = true
+ return@forEachIndexed
}
+ enqueued.add(id)
+ downloadIds[i] = id
+ }
+
+ if (enqueueFailed) {
+ // A partial enqueue would leave already-started downloads running with no worker
+ // observing them. Cancel the ones we managed to start before reporting failure.
+ Logger.w(LOG_TAG_DNS, "partial local blocklist enqueue; cancelling orphans")
+ cancelAndroidDownloadManagerDownloads(enqueued)
+ val msg = context.getString(R.string.download_err_system_manager)
+ downloadState.postValue(DownloadState.Error(msg))
+ persistentState.lastDownloadFailureReason = msg
+ return DownloadManagerStatus.FAILURE
}
+
// Store download IDs for later cancellation
persistentState.androidDownloadManagerIds = downloadIds.joinToString(",")
initiateDownloadStatusCheck(downloadIds, timestamp)
@@ -290,11 +483,12 @@ class AppDownloadManager(
}
suspend fun downloadRemoteBlocklist(currentTs: Long, isRedownload: Boolean): Boolean {
-
val response = checkBlocklistUpdate(currentTs, persistentState.appVersion, retryCount = 0, persistentState.routeRethinkInRethink)
// if received response for update is null
if (response == null) {
+ val msg = context.getString(R.string.download_err_network)
Logger.w(LOG_TAG_DNS, "remote blocklist update check is null")
+ downloadState.postValue(DownloadState.Error(msg))
downloadRequired.postValue(DownloadManagerStatus.FAILURE)
return false
}
@@ -303,7 +497,9 @@ class AppDownloadManager(
// Guard: an INIT_TIME_MS (0) timestamp means the server returned an unexpected version.
if (updatableTs == INIT_TIME_MS) {
+ val msg = context.getString(R.string.download_err_internal)
Logger.w(LOG_TAG_DNS, "remote blocklist: updatableTs is (0), aborting download")
+ downloadState.postValue(DownloadState.Error(msg))
return false
}
@@ -312,11 +508,16 @@ class AppDownloadManager(
LOG_TAG_DNS,
"remote blocklist update not required, current ts: $currentTs, updatable ts: $updatableTs"
)
+ downloadState.postValue(DownloadState.Idle)
return false
} else {
// no-op
}
+ // A download is about to start; publish the Starting state only now (posting it
+ // earlier would make the Idle transition above get rejected by shouldUpdateState,
+ // leaving the UI stuck on "Starting").
+ downloadState.postValue(DownloadState.Starting)
return initiateRemoteBlocklistDownload(updatableTs)
}
@@ -379,6 +580,7 @@ class AppDownloadManager(
val data = Data.Builder()
data.putLong("workerStartTime", SystemClock.elapsedRealtime())
data.putLongArray("downloadIds", downloadIds)
+ data.putLong("blocklistTimestamp", timestamp)
val downloadWatcher =
OneTimeWorkRequestBuilder()
@@ -392,7 +594,10 @@ class AppDownloadManager(
.setInitialDelay(WORK_INITIAL_DELAY_SECONDS, TimeUnit.SECONDS)
.build()
- val timestampWorkerData = workDataOf("blocklistDownloadInitiatedTime" to timestamp)
+ val timestampWorkerData = workDataOf(
+ "blocklistDownloadInitiatedTime" to timestamp,
+ "blocklistTimestamp" to timestamp
+ )
val fileHandler =
OneTimeWorkRequestBuilder()
@@ -419,7 +624,7 @@ class AppDownloadManager(
private fun enqueueDownload(url: String, fileName: String, timestamp: String): Long {
try {
downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
- val downloadUri = Uri.parse(url)
+ val downloadUri = url.toUri()
val request = DownloadManager.Request(downloadUri)
request.apply {
setTitle(fileName)
diff --git a/app/src/main/java/com/celzero/bravedns/download/BlocklistDownloadHelper.kt b/app/src/main/java/com/celzero/bravedns/download/BlocklistDownloadHelper.kt
index 0e1c2d3638..d4090e8fa4 100644
--- a/app/src/main/java/com/celzero/bravedns/download/BlocklistDownloadHelper.kt
+++ b/app/src/main/java/com/celzero/bravedns/download/BlocklistDownloadHelper.kt
@@ -29,6 +29,7 @@ import org.json.JSONException
import org.json.JSONObject
import retrofit2.converter.gson.GsonConverterFactory
import java.io.File
+import java.util.concurrent.CancellationException
class BlocklistDownloadHelper {
@@ -177,6 +178,9 @@ class BlocklistDownloadHelper {
val r = response.body()?.toString()?.let { JSONObject(it) }
return processCheckDownloadResponse(r)
}
+ } catch (ex: CancellationException) {
+ // never swallow cooperative cancellation
+ throw ex
} catch (ex: Exception) {
logw("exception in checkBlocklistUpdate: ${ex.message}", ex)
}
diff --git a/app/src/main/java/com/celzero/bravedns/download/DownloadWatcher.kt b/app/src/main/java/com/celzero/bravedns/download/DownloadWatcher.kt
index ec29e5504e..48263e22d1 100644
--- a/app/src/main/java/com/celzero/bravedns/download/DownloadWatcher.kt
+++ b/app/src/main/java/com/celzero/bravedns/download/DownloadWatcher.kt
@@ -22,6 +22,8 @@ import android.content.Context
import android.os.SystemClock
import androidx.work.Worker
import androidx.work.WorkerParameters
+import androidx.work.workDataOf
+import com.celzero.bravedns.R
import com.celzero.bravedns.service.PersistentState
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
@@ -41,6 +43,10 @@ class DownloadWatcher(val context: Context, workerParameters: WorkerParameters)
// The time out value is set as 40 minutes.
val ONDEVICE_BLOCKLIST_DOWNLOAD_TIMEOUT_MS = TimeUnit.MINUTES.toMillis(40)
+ // How long the downloads may stay at 0 bytes / total -1 (never started) before
+ // the watcher gives up and fails, instead of retrying until the 40-minute timeout.
+ val ONDEVICE_BLOCKLIST_DOWNLOAD_NOT_STARTED_TIMEOUT_MS = TimeUnit.MINUTES.toMillis(5)
+
// various download status used as part of Work manager. see
// DownloadWatcher#checkForDownload()
const val DOWNLOAD_FAILURE = -1
@@ -51,19 +57,63 @@ class DownloadWatcher(val context: Context, workerParameters: WorkerParameters)
private var downloadIds: MutableList? = mutableListOf()
private val persistentState by inject()
+ /**
+ * Pure interpretation of a DownloadManager row into a terminal/continue outcome.
+ * Kept as an explicit, testable function so unknown statuses fail fast instead of
+ * being silently retried forever (see [classify]).
+ */
+ internal object Interpreter {
+ const val CONTINUE = 0
+ const val SUCCESS = 1
+ const val FAILURE = 2
+
+ fun classify(status: Int, reason: Int): Int {
+ return when (status) {
+ DownloadManager.STATUS_SUCCESSFUL -> SUCCESS
+ DownloadManager.STATUS_FAILED -> FAILURE
+ // Still in flight (or paused); the Worker will retry and re-check.
+ DownloadManager.STATUS_PENDING,
+ DownloadManager.STATUS_RUNNING,
+ DownloadManager.STATUS_PAUSED -> CONTINUE
+ // Any other value (0, -1, or a value not recognised by this build) is
+ // treated as a terminal failure so the Worker cannot loop forever.
+ else -> FAILURE
+ }
+ }
+
+ fun reasonToResId(reason: Int): Int {
+ return when (reason) {
+ DownloadManager.ERROR_CANNOT_RESUME,
+ DownloadManager.ERROR_HTTP_DATA_ERROR,
+ DownloadManager.ERROR_TOO_MANY_REDIRECTS,
+ DownloadManager.ERROR_UNHANDLED_HTTP_CODE -> R.string.download_err_network
+ DownloadManager.ERROR_DEVICE_NOT_FOUND -> R.string.download_err_system_manager
+ DownloadManager.ERROR_FILE_ALREADY_EXISTS,
+ DownloadManager.ERROR_FILE_ERROR -> R.string.download_err_storage
+ DownloadManager.ERROR_INSUFFICIENT_SPACE -> R.string.download_err_storage
+ DownloadManager.ERROR_UNKNOWN -> R.string.download_err_internal
+ else -> R.string.download_err_internal
+ }
+ }
+ }
+
override fun doWork(): Result {
Logger.i(LOG_TAG_DOWNLOAD, "start download watcher, checking for download status")
val startTime = inputData.getLong("workerStartTime", 0)
downloadIds = inputData.getLongArray("downloadIds")?.toMutableList()
Logger.d(LOG_TAG_DOWNLOAD, "AppDownloadManager: $startTime, $downloadIds")
- if (downloadIds == null || downloadIds?.isEmpty() == true) return Result.failure()
+ if (downloadIds == null || downloadIds?.isEmpty() == true) {
+ persistentState.lastDownloadFailureReason = context.getString(R.string.download_err_system_manager)
+ return Result.failure()
+ }
if (SystemClock.elapsedRealtime() - startTime > ONDEVICE_BLOCKLIST_DOWNLOAD_TIMEOUT_MS) {
+ persistentState.lastDownloadFailureReason = context.getString(R.string.download_err_network)
return Result.failure()
}
- when (checkForDownload(context, downloadIds)) {
+ when (checkForDownload(context, downloadIds, startTime)) {
DOWNLOAD_RETRY -> {
return Result.retry()
}
@@ -71,8 +121,10 @@ class DownloadWatcher(val context: Context, workerParameters: WorkerParameters)
return Result.failure()
}
DOWNLOAD_SUCCESS -> {
- // Clear the stored download IDs on successful completion
+ // Clear the stored download IDs on successful completion and reset any
+ // previously-recorded failure reason so it cannot leak into a later Error.
clearStoredDownloadIds()
+ persistentState.lastDownloadFailureReason = ""
return Result.success()
}
}
@@ -89,60 +141,128 @@ class DownloadWatcher(val context: Context, workerParameters: WorkerParameters)
}
}
- private fun checkForDownload(context: Context, downloadIds: MutableList?): Int {
- // check for the download success from the receiver
+ private fun checkForDownload(
+ context: Context,
+ downloadIds: MutableList?,
+ startTimeMs: Long
+ ): Int {
+ val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
+ var totalBytes = 0L
+ var downloadedBytes = 0L
+ var anyFailed = false
+ var failureReason = ""
+
val downloadIdsIterator = downloadIds?.iterator()
while (downloadIdsIterator?.hasNext() == true) {
val downloadID = downloadIdsIterator.next()
val query = DownloadManager.Query()
query.setFilterById(downloadID)
- val downloadManager =
- context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
val cursor = downloadManager.query(query)
if (cursor == null) {
Logger.i(LOG_TAG_DOWNLOAD, "status is $downloadID cursor null")
- return DOWNLOAD_FAILURE
+ anyFailed = true
+ failureReason = context.getString(R.string.download_err_system_manager)
+ break
}
try {
- val columnIndex = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
- if (columnIndex == -1) {
+ val statusIdx = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
+ val reasonIdx = cursor.getColumnIndex(DownloadManager.COLUMN_REASON)
+ val downloadedIdx = cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
+ val totalIdx = cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
+
+ if (statusIdx == -1) {
Logger.i(LOG_TAG_DOWNLOAD, "status is $downloadID column index -1")
- return DOWNLOAD_FAILURE
+ anyFailed = true
+ failureReason = context.getString(R.string.download_err_system_manager)
+ break
}
+
if (cursor.moveToFirst()) {
- val status = cursor.getInt(columnIndex)
-
- Logger.d(LOG_TAG_DOWNLOAD, "onReceive status $status $downloadID")
-
- if (status == DownloadManager.STATUS_SUCCESSFUL) {
- downloadIdsIterator.remove()
- } else if (status == DownloadManager.STATUS_FAILED) {
- val reason = cursor.getInt(columnIndex)
- Logger.d(
- LOG_TAG_DOWNLOAD,
- "download status failure for $downloadID, $reason"
- )
- return DOWNLOAD_FAILURE
+ val status = cursor.getInt(statusIdx)
+ val reason = if (reasonIdx != -1) cursor.getInt(reasonIdx) else -1
+ val downloaded = if (downloadedIdx != -1) cursor.getLong(downloadedIdx) else 0L
+ val total = if (totalIdx != -1) cursor.getLong(totalIdx) else -1L
+
+ Logger.d(LOG_TAG_DOWNLOAD, "onReceive status $status $downloadID, reason $reason, progress $downloaded/$total")
+
+ if (total > 0) {
+ totalBytes += total
+ downloadedBytes += downloaded
+ }
+
+ when (Interpreter.classify(status, reason)) {
+ Interpreter.SUCCESS -> {
+ downloadIdsIterator.remove()
+ }
+ Interpreter.FAILURE -> {
+ Logger.d(
+ LOG_TAG_DOWNLOAD,
+ "download status failure for $downloadID, $reason"
+ )
+ anyFailed = true
+ failureReason = context.getString(Interpreter.reasonToResId(reason))
+ break
+ }
+ Interpreter.CONTINUE -> {
+ // still downloading / paused / pending; keep waiting
+ }
}
} else {
Logger.d(LOG_TAG_DOWNLOAD, "cursor empty")
- return DOWNLOAD_FAILURE
+ anyFailed = true
+ failureReason = context.getString(R.string.download_err_system_manager)
+ break
}
} catch (e: Exception) {
Logger.e(LOG_TAG_DOWNLOAD, "failure download: ${e.message}", e)
+ anyFailed = true
+ failureReason = context.getString(R.string.download_err_internal)
+ break
} finally {
cursor.close()
}
}
+ if (anyFailed) {
+ persistentState.lastDownloadFailureReason = failureReason
+ return DOWNLOAD_FAILURE
+ }
+
// send the status as success when the download ids are cleared
if (downloadIds?.isEmpty() == true) {
Logger.i(LOG_TAG_DOWNLOAD, "files downloaded successfully")
return DOWNLOAD_SUCCESS
}
+ // Update progress if possible
+ if (totalBytes > 0) {
+ val progress = (downloadedBytes * 100 / totalBytes).toInt()
+ setProgressAsync(workDataOf("progress" to progress))
+ }
+
+ // Fail fast when the platform download provider never starts the transfer:
+ // every download still reports total-size -1 (no response headers received)
+ // and zero bytes downloaded, ie, the downloads are stuck in STATUS_PENDING.
+ // Left alone, this loops until the full 40-minute timeout with no user-visible
+ // error, and (because the DOWNLOAD_WORKER chain stays ENQUEUED) blocks every
+ // new download attempt with a bare FAILURE until then. Seen when the provider
+ // defers the job (data saver on metered network, battery saver) or when it is
+ // disabled/force-stopped on some OEM ROMs.
+ if (downloadedBytes == 0L && totalBytes == 0L) {
+ val elapsedMs = SystemClock.elapsedRealtime() - startTimeMs
+ if (elapsedMs > ONDEVICE_BLOCKLIST_DOWNLOAD_NOT_STARTED_TIMEOUT_MS) {
+ Logger.w(
+ LOG_TAG_DOWNLOAD,
+ "downloads($downloadIds) never started after $elapsedMs ms; failing"
+ )
+ persistentState.lastDownloadFailureReason =
+ context.getString(R.string.download_err_system_manager)
+ return DOWNLOAD_FAILURE
+ }
+ }
+
// occasionally, the download-manager observer fires without a download having
// been enqueued and download-ids populated into persistent-state, which keep in
// mind, is also eventually consistent with its state propagation. In this case,
diff --git a/app/src/main/java/com/celzero/bravedns/download/FileHandleWorker.kt b/app/src/main/java/com/celzero/bravedns/download/FileHandleWorker.kt
index cbf2cb2a79..162479c66b 100644
--- a/app/src/main/java/com/celzero/bravedns/download/FileHandleWorker.kt
+++ b/app/src/main/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.R
import com.celzero.bravedns.download.BlocklistDownloadHelper.Companion.deleteBlocklistResidue
import com.celzero.bravedns.download.BlocklistDownloadHelper.Companion.deleteOldFiles
import com.celzero.bravedns.service.PersistentState
@@ -33,9 +34,6 @@ import com.celzero.bravedns.util.Utilities.calculateMd5
import com.celzero.bravedns.util.Utilities.getTagValueFromJson
import com.celzero.bravedns.util.Utilities.hasLocalBlocklists
import com.celzero.bravedns.util.Utilities.localBlocklistFileDownloadPath
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.File
@@ -55,13 +53,16 @@ class FileHandleWorker(val context: Context, workerParameters: WorkerParameters)
val persistentState by inject()
override suspend fun doWork(): Result {
+ setProgress(workDataOf("processing" to true))
try {
val timestamp = inputData.getLong("blocklistDownloadInitiatedTime", Long.MIN_VALUE)
Logger.d(LOG_TAG_DOWNLOAD, "blocklistDownloadInitiatedTime - $timestamp")
// invalid download initiated time
if (timestamp <= INIT_TIME_MS) {
- Logger.w(LOG_TAG_DOWNLOAD, "timestamp version invalid $timestamp")
+ val msg = context.getString(R.string.download_err_internal)
+ Logger.w(LOG_TAG_DOWNLOAD, "Invalid timestamp: $timestamp")
+ persistentState.lastDownloadFailureReason = msg
return Result.failure()
}
@@ -73,11 +74,9 @@ class FileHandleWorker(val context: Context, workerParameters: WorkerParameters)
return if (response) Result.success(outputData) else Result.failure()
} catch (e: Exception) {
- Logger.e(
- LOG_TAG_DOWNLOAD,
- "FileHandleWorker Error while moving files to canonical path ${e.message}",
- e
- )
+ val msg = context.getString(R.string.download_err_internal)
+ Logger.e(LOG_TAG_DOWNLOAD, "Processing failure: ${e.message}", e)
+ persistentState.lastDownloadFailureReason = msg
}
return Result.failure()
}
@@ -85,22 +84,24 @@ class FileHandleWorker(val context: Context, workerParameters: WorkerParameters)
private suspend fun copyFiles(context: Context, timestamp: Long): Boolean {
try {
if (!BlocklistDownloadHelper.isDownloadComplete(context, timestamp)) {
+ persistentState.lastDownloadFailureReason = context.getString(R.string.download_err_internal)
return false
}
val dir =
File(BlocklistDownloadHelper.getExternalFilePath(context, timestamp.toString()))
if (!dir.isDirectory) {
- Logger.w(
- LOG_TAG_DOWNLOAD,
- "Abort: file download path ${dir.absolutePath} isn't a directory"
- )
+ val msg = context.getString(R.string.download_err_storage)
+ Logger.w(LOG_TAG_DOWNLOAD, "Download directory missing: ${dir.absolutePath}")
+ persistentState.lastDownloadFailureReason = msg
return false
}
val children = dir.list()
if (children.isNullOrEmpty()) {
- Logger.w(LOG_TAG_DOWNLOAD, "Abort: ${dir.absolutePath} is empty directory")
+ val msg = context.getString(R.string.download_err_internal)
+ Logger.w(LOG_TAG_DOWNLOAD, "Download directory empty: ${dir.absolutePath}")
+ persistentState.lastDownloadFailureReason = msg
return false
}
@@ -112,13 +113,17 @@ class FileHandleWorker(val context: Context, workerParameters: WorkerParameters)
val from = dir.absolutePath + File.separator + children[i]
val to = localBlocklistFileDownloadPath(context, children[i], timestamp)
if (to.isEmpty()) {
- Logger.w(LOG_TAG_DOWNLOAD, "Copy failed from $from, to: $to")
+ val msg = context.getString(R.string.download_err_internal)
+ Logger.w(LOG_TAG_DOWNLOAD, "Copy failed: destination path empty for ${children[i]}")
+ persistentState.lastDownloadFailureReason = msg
return false
}
val result = Utilities.copy(from, to)
if (!result) {
- Logger.w(LOG_TAG_DOWNLOAD, "Copy failed from: $from, to: $to")
+ val msg = context.getString(R.string.download_err_storage)
+ Logger.w(LOG_TAG_DOWNLOAD, "Copy failed from $from to $to")
+ persistentState.lastDownloadFailureReason = msg
return false
}
}
@@ -132,11 +137,21 @@ class FileHandleWorker(val context: Context, workerParameters: WorkerParameters)
"After copy, dest dir: $destinationDir, ${destinationDir.isDirectory}, ${destinationDir.list()?.count()}"
)
- if (!hasLocalBlocklists(context, timestamp) || !isDownloadValid(timestamp)) {
+ if (!hasLocalBlocklists(context, timestamp)) {
+ persistentState.lastDownloadFailureReason = context.getString(R.string.download_err_validation)
+ return false
+ }
+
+ if (!isDownloadValid(timestamp)) {
+ persistentState.lastDownloadFailureReason = context.getString(R.string.download_err_validation)
return false
}
val result = updateTagsToDb(timestamp)
+ if (!result) {
+ persistentState.lastDownloadFailureReason = context.getString(R.string.download_err_internal)
+ return false
+ }
updatePersistenceOnCopySuccess(timestamp)
// delete the old files in the external directory (downloaded by the download manager)
@@ -150,7 +165,9 @@ class FileHandleWorker(val context: Context, workerParameters: WorkerParameters)
Logger.i(LOG_TAG_DOWNLOAD, "FileHandleWorker, copyFiles success? $result")
return true
} catch (e: Exception) {
- Logger.e(LOG_TAG_DOWNLOAD, "FileHandleWorker Copy exception: ${e.message}", e)
+ val msg = context.getString(R.string.download_err_internal)
+ Logger.e(LOG_TAG_DOWNLOAD, "Copy files exception: ${e.message}", e)
+ persistentState.lastDownloadFailureReason = msg
}
return false
}
@@ -163,12 +180,14 @@ class FileHandleWorker(val context: Context, workerParameters: WorkerParameters)
)
}
+ // Must write synchronously: copyFiles() deletes blocklist residue keyed on
+ // localBlocklistTimestamp right after this call. An async write would let the
+ // residue-sweep read the *old* timestamp and delete the freshly downloaded
+ // directory as "residue".
private fun updatePersistenceOnCopySuccess(timestamp: Long) {
- ui {
- persistentState.localBlocklistTimestamp = timestamp
- persistentState.newestLocalBlocklistTimestamp = INIT_TIME_MS
- persistentState.blocklistEnabled = true
- }
+ persistentState.localBlocklistTimestamp = timestamp
+ persistentState.newestLocalBlocklistTimestamp = INIT_TIME_MS
+ persistentState.blocklistEnabled = true
}
/**
@@ -203,8 +222,4 @@ class FileHandleWorker(val context: Context, workerParameters: WorkerParameters)
}
return false
}
-
- private fun ui(f: suspend () -> Unit) {
- CoroutineScope(Dispatchers.Main).launch { f() }
- }
}
diff --git a/app/src/main/java/com/celzero/bravedns/glide/RethinkGlideModule.kt b/app/src/main/java/com/celzero/bravedns/glide/RethinkGlideModule.kt
index 51ddf22032..1cceebff4e 100644
--- a/app/src/main/java/com/celzero/bravedns/glide/RethinkGlideModule.kt
+++ b/app/src/main/java/com/celzero/bravedns/glide/RethinkGlideModule.kt
@@ -36,6 +36,7 @@ import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory
import com.bumptech.glide.module.AppGlideModule
import okhttp3.OkHttpClient
+import java.net.Proxy
import java.io.InputStream
import java.nio.ByteBuffer
import java.security.MessageDigest
@@ -80,6 +81,10 @@ class RethinkGlideModule : AppGlideModule() {
val client: OkHttpClient = OkHttpClient.Builder()
.readTimeout(5, TimeUnit.SECONDS)
.connectTimeout(3, TimeUnit.SECONDS)
+ // Pin NO_PROXY: with no explicit proxy, OkHttp consults ProxySelector.getDefault(),
+ // which crashes (IllegalArgumentException: port out of range:-1) on devices where
+ // the platform set http(s).proxyPort=-1 for a global proxy without a port.
+ .proxy(Proxy.NO_PROXY)
.build()
registry.replace(GlideUrl::class.java, InputStream::class.java, OkHttpUrlLoader.Factory(client))
diff --git a/app/src/main/java/com/celzero/bravedns/iab/DeviceRegistrationGuard.kt b/app/src/main/java/com/celzero/bravedns/iab/DeviceRegistrationGuard.kt
new file mode 100644
index 0000000000..58629336af
--- /dev/null
+++ b/app/src/main/java/com/celzero/bravedns/iab/DeviceRegistrationGuard.kt
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2026 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.iab
+
+import com.celzero.bravedns.util.Logger
+import com.celzero.bravedns.util.Logger.LOG_IAB
+import java.util.concurrent.atomic.AtomicLong
+
+/**
+ * Time-gated single-flight guard for the periodic device-registration check
+ * (`checkAndRegisterDeviceIfNeeded`) that is implemented in **both**
+ * [com.celzero.bravedns.scheduler.RpnProxyUpdateWorker] and [SubscriptionCheckWorker].
+ *
+ * ### Why this exists
+ * `RpnProxyUpdateWorker` (45-min periodic) enqueues [SubscriptionCheckWorker] at the top
+ * of every run and *also* executes its own registration check — so during the daily
+ * registration window both workers (plus `RpnProxyManager.registerProxy`) can execute
+ * reconcile paths concurrently. Historically each reconcile minted a fresh DID
+ * (`POST /d/reg` with a blank `did` header) on any CID mismatch, producing two token
+ * seeds per device per account (visible as duplicate `/d/reg` wire-log lines at the
+ * same second).
+ *
+ * This guard coalesces those overlapping runs: the first caller wins and sets the
+ * timestamp; every other caller within [MIN_INTERVAL_MS] is skipped. The purchase flow
+ * (`PurchasesUpdatedListener` → `reconcileDidForCid`) does **not** go through this guard
+ * and remains unaffected.
+ *
+ * ### Semantics
+ * - [tryBegin] is atomic (CAS on the timestamp): exactly one concurrent caller proceeds.
+ * - The timestamp is stamped at **begin**, not completion: a slow/hung check still blocks
+ * overlapping periodic runs for the window duration. Workers re-fire every 45 min, so
+ * a skipped run only delays the check by one period.
+ * - Callers **must** call [end] in a `finally` block so a later run inside the window
+ * sees a stable timestamp (no-op today, kept for future end-stamping needs).
+ */
+object DeviceRegistrationGuard {
+
+ private const val TAG = "DeviceRegistrationGuard"
+
+ /** Minimum interval between two device-registration checks. */
+ private const val MIN_INTERVAL_MS = 10 * 60 * 1000L // 10 minutes
+
+ private val lastRunStartMs = AtomicLong(0L)
+
+ /**
+ * Returns `true` (and stamps the current time) when the caller may proceed with a
+ * device-registration check; `false` when a check already ran within [MIN_INTERVAL_MS].
+ */
+ fun tryBegin(caller: String): Boolean {
+ val now = System.currentTimeMillis()
+ while (true) {
+ val last = lastRunStartMs.get()
+ if (last != 0L && (now - last) < MIN_INTERVAL_MS) {
+ Logger.i(LOG_IAB, "$TAG; $caller: skipped, a device-registration check " +
+ "ran ${now - last}ms ago (window=${MIN_INTERVAL_MS}ms)")
+ return false
+ }
+ if (lastRunStartMs.compareAndSet(last, now)) {
+ Logger.i(LOG_IAB, "$TAG; $caller: acquired, running device-registration check")
+ return true
+ }
+ // Lost the race with another begin(); loop re-reads and re-evaluates.
+ }
+ }
+
+ /** Marks the end of a device-registration check. Call from a `finally` block. */
+ fun end(caller: String) {
+ Logger.v(LOG_IAB, "$TAG; $caller: device-registration check ended")
+ }
+}
diff --git a/app/src/main/java/com/celzero/bravedns/net/go/GoVpnAdapter.kt b/app/src/main/java/com/celzero/bravedns/net/go/GoVpnAdapter.kt
index a30b4e5799..11dfef72a7 100644
--- a/app/src/main/java/com/celzero/bravedns/net/go/GoVpnAdapter.kt
+++ b/app/src/main/java/com/celzero/bravedns/net/go/GoVpnAdapter.kt
@@ -42,6 +42,7 @@ import com.celzero.bravedns.database.EventSource
import com.celzero.bravedns.database.EventType
import com.celzero.bravedns.database.ProxyEndpoint
import com.celzero.bravedns.database.Severity
+import com.celzero.bravedns.database.SmartDnsMode
import com.celzero.bravedns.net.doh.Transaction
import com.celzero.bravedns.rpnproxy.RpnProxyManager
import com.celzero.bravedns.rpnproxy.RpnProxyManager.AUTO_SERVER_ID
@@ -163,6 +164,7 @@ class GoVpnAdapter : KoinComponent {
// TODO: ideally the values required for transport, alg and rdns should be set in the
// opts itself.
setRDNS()
+ setPlusStrategy()
addTransport()
setWireguardTunnelModeIfNeeded(opts.tunProxyMode)
setSocks5TunnelModeIfNeeded(opts.tunProxyMode)
@@ -3609,10 +3611,6 @@ class GoVpnAdapter : KoinComponent {
// default transport-id(Plus), append index & individual id with this
val id = Backend.Plus + DOT_INDEX + dot.id
url = dot.url
- // skip mullvad dots
- if (url.contains("mullvad.net") || url.contains("mullvad.org")) {
- return@io
- }
// if tls is present, remove it and pass it to getIpString
val ips: String = getIpString(context, url.replace("tls://", ""))
if (ips.isEmpty()) {
@@ -3708,12 +3706,18 @@ class GoVpnAdapter : KoinComponent {
return false
}
- fun setPlusStrategy(option: Long): Tunnel {
- // Settings.PlusFilterSafest, Settings.PlusOrderFastest
- // default value for PlusStrategy is Safest, which is the safest strategy
- // fastest is another strategy, which is not used for now (v055n)
- Settings.setPlusStrategy(Settings.PlusFilterSafest)
- return tunnel
+ suspend fun setPlusStrategy() {
+ if (appConfig.getDnsType().isSmartDns()) {
+ val chosenSmartDns = appConfig.getSelectedSmartDnsEndpoint()
+ if (chosenSmartDns == null) {
+ Settings.setPlusStrategy(Settings.PlusOrderFastest, Settings.PlusFilterAdblock)
+ } else {
+ val mode = SmartDnsMode.getTunMode(chosenSmartDns.id)
+ Settings.setPlusStrategy(Settings.PlusOrderFastest, mode)
+ }
+ } else {
+ Settings.setPlusStrategy(Settings.PlusOrderFastest, Settings.PlusFilterAdblock)
+ }
}
fun tunMtu(): Int {
diff --git a/app/src/main/java/com/celzero/bravedns/receiver/VPNControlReceiver.kt b/app/src/main/java/com/celzero/bravedns/receiver/VPNControlReceiver.kt
index a9a8412ea6..6457ed048d 100644
--- a/app/src/main/java/com/celzero/bravedns/receiver/VPNControlReceiver.kt
+++ b/app/src/main/java/com/celzero/bravedns/receiver/VPNControlReceiver.kt
@@ -88,6 +88,9 @@ class VpnControlReceiver: BroadcastReceiver(), KoinComponent {
// 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
+ } catch (e: IllegalStateException) {
+ Logger.w(LOG_TAG_VPN, "$TAG VPN unavailable: in lockdown mode", e)
+ return
}
if (prepareVpnIntent == null) {
Logger.i(LOG_TAG_VPN, "$TAG VPN is prepared, invoking start")
diff --git a/app/src/main/java/com/celzero/bravedns/rpnproxy/RpnProxyManager.kt b/app/src/main/java/com/celzero/bravedns/rpnproxy/RpnProxyManager.kt
index dcbecac300..7e7958d0ad 100644
--- a/app/src/main/java/com/celzero/bravedns/rpnproxy/RpnProxyManager.kt
+++ b/app/src/main/java/com/celzero/bravedns/rpnproxy/RpnProxyManager.kt
@@ -64,8 +64,11 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
@@ -162,6 +165,86 @@ object RpnProxyManager : KoinComponent {
)
val serverRemovedEvent: SharedFlow> = _serverRemovedEvent.asSharedFlow()
+ /**
+ * Outcome of a single RPN reachability (ping) test, as shown in the
+ * ping-test history list of [com.celzero.bravedns.ui.activity.PingTestActivity].
+ */
+ enum class PingTestOutcome(val id: String) {
+ SUCCESS("success"),
+ PARTIAL("partial"),
+ FAILURE("failure");
+
+ companion object {
+ fun fromId(id: String): PingTestOutcome =
+ entries.firstOrNull { it.id == id } ?: FAILURE
+ }
+ }
+
+ /**
+ * One recorded reachability test. Kept in-memory only (lifetime of this
+ * singleton — i.e. the app process); NOT persisted. Surfaced to the UI via
+ * [pingTestHistory] (newest first).
+ */
+ data class PingTestHistoryEntry(
+ val timestamp: Long, // when the test completed; also the unique identity
+ val targets: String, // CSV of tested targets; blank = AUTO (default probes)
+ val outcome: String, // see [PingTestOutcome.id]
+ val latencyMs: Long, // total wall-clock duration of the test
+ val passed: Int, // number of targets that were reachable
+ val total: Int // total number of targets tested (1 for AUTO)
+ ) {
+ fun outcomeEnum(): PingTestOutcome = PingTestOutcome.fromId(outcome)
+ fun isAuto(): Boolean = targets.isBlank()
+ }
+
+ private const val MAX_PING_TEST_HISTORY = 20
+
+ // Insertion-ordered set of recent tests (oldest first internally); guarded by
+ // [pingTestHistoryMutex]. In-memory only by design — history resets when the
+ // app process dies.
+ private val pingTestHistorySet = LinkedHashSet()
+ private val pingTestHistoryMutex = Mutex()
+
+ private val _pingTestHistory = MutableStateFlow>(emptyList())
+
+ /**
+ * Recent ping-test results, newest first. Collect in the UI to render the
+ * history list; the current value is also available immediately.
+ */
+ val pingTestHistory: StateFlow> = _pingTestHistory.asStateFlow()
+
+ /**
+ * Records a completed reachability test into the history (capped at
+ * [MAX_PING_TEST_HISTORY], deduped by timestamp). Safe to call from any
+ * thread; the set mutation runs on io.
+ */
+ fun recordPingTest(targets: String, outcome: PingTestOutcome, latencyMs: Long, passed: Int, total: Int) {
+ io {
+ try {
+ val entry = PingTestHistoryEntry(
+ timestamp = System.currentTimeMillis(),
+ targets = targets,
+ outcome = outcome.id,
+ latencyMs = latencyMs,
+ passed = passed,
+ total = total
+ )
+ val snapshot: List
+ pingTestHistoryMutex.withLock {
+ pingTestHistorySet.add(entry)
+ while (pingTestHistorySet.size > MAX_PING_TEST_HISTORY) {
+ pingTestHistorySet.remove(pingTestHistorySet.first())
+ }
+ snapshot = pingTestHistorySet.toList().sortedByDescending { it.timestamp }
+ }
+ _pingTestHistory.value = snapshot
+ Logger.d(LOG_TAG_PROXY, "$TAG; recorded ping test: $entry")
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_PROXY, "$TAG; error recording ping test: ${e.message}", e)
+ }
+ }
+ }
+
private val subscriptionStateMachine: SubscriptionStateMachineV2 by inject()
private val stateObserverJob = SupervisorJob()
private val stateObserverScope = CoroutineScope(Dispatchers.IO + stateObserverJob)
@@ -421,7 +504,7 @@ object RpnProxyManager : KoinComponent {
// Check if current state allows RPN activation
if (!subscriptionStateMachine.hasValidSubscription()) {
- val currentState = subscriptionStateMachine.getCurrentState()
+ val currentState = subscriptionStateMachine.currentMachineState()
Logger.w(LOG_TAG_PROXY, "$TAG; activateRpn: cannot activate RPN - no valid subscription, current state: ${currentState.name}")
return
}
@@ -1360,7 +1443,7 @@ object RpnProxyManager : KoinComponent {
* Used by UI to display the current state of the subscription.
*/
fun getSubscriptionState(): SubscriptionStateMachineV2.SubscriptionState {
- return subscriptionStateMachine.getCurrentState()
+ return subscriptionStateMachine.currentMachineState()
}
fun getCurrentSubscription(): SubscriptionStateMachineV2.SubscriptionData? {
@@ -2407,6 +2490,8 @@ object RpnProxyManager : KoinComponent {
winCacheMutex.withLock {
winServersCache.filter { it.key == key }.forEach { it.isEnabled = true }
}
+ config.catchAll = true
+ config.lockdown = true
config.isEnabled = true
try {
countryConfigRepo.update(config)
@@ -2419,6 +2504,7 @@ object RpnProxyManager : KoinComponent {
winCacheMutex.withLock {
winServersCache.filter { it.key == key }.forEach { it.isEnabled = false }
}
+ config.catchAll = false
config.isEnabled = false
return Pair(false, "Failed to update database: ${e.message}")
}
@@ -2716,7 +2802,7 @@ object RpnProxyManager : KoinComponent {
isActive = true,
isEnabled = false, // Not enabled by default
catchAll = true,
- lockdown = false,
+ lockdown = true,
mobileOnly = false,
ssidBased = false,
priority = 999, // Highest priority so it appears first
@@ -2763,6 +2849,20 @@ object RpnProxyManager : KoinComponent {
}
}
+ /**
+ * True when the AUTO sentinel has automation (mobile-only or
+ * SSID-based) enabled
+ */
+ suspend fun isAutoAutomationEnabled(): Boolean {
+ return try {
+ val auto = getAutoServer() ?: return false
+ auto.mobileOnly || auto.ssidBased
+ } catch (e: Exception) {
+ Logger.w(LOG_TAG_PROXY, "$TAG; isAutoAutomationEnabled: err: ${e.message}")
+ false
+ }
+ }
+
/**
* Updates AUTO server state in database and cache
*/
diff --git a/app/src/main/java/com/celzero/bravedns/rpnproxy/SubscriptionStateMachineV2.kt b/app/src/main/java/com/celzero/bravedns/rpnproxy/SubscriptionStateMachineV2.kt
index 1dc8f1f984..16053192a1 100644
--- a/app/src/main/java/com/celzero/bravedns/rpnproxy/SubscriptionStateMachineV2.kt
+++ b/app/src/main/java/com/celzero/bravedns/rpnproxy/SubscriptionStateMachineV2.kt
@@ -1069,9 +1069,17 @@ open class SubscriptionStateMachineV2 : KoinComponent {
*/
private suspend fun updateCancelledStatusInDb(detail: PurchaseDetail) {
try {
- val existing = subscriptionDb.getByPurchaseToken(detail.purchaseToken)
- ?: subscriptionDb.getCurrentSubscription()
- ?: return
+ // Token-strict lookup: this function unconditionally writes CANCELLED, so it
+ // must NEVER fall back to getCurrentSubscription() — a Play snapshot whose
+ // token is unknown to the DB would otherwise stamp "the most recent row"
+ // (possibly a different, ACTIVE purchase) as CANCELLED. When the token is
+ // unknown, handlePaymentSuccessful is the correct writer: it creates the row
+ // with targetStatus derived from Play (CANCELLED for isAutoRenewing=false).
+ val existing = subscriptionDb.getByPurchaseToken(detail.purchaseToken) ?: run {
+ Logger.w(LOG_IAB, "$TAG: updateCancelledStatusInDb: no DB row for token " +
+ "${detail.purchaseToken.take(8)}, skipping (token-strict)")
+ return
+ }
if (existing.status == SubscriptionStatus.SubscriptionState.STATE_CANCELLED.id) {
Logger.d(LOG_IAB, "$TAG: updateCancelledStatusInDb: already CANCELLED, no-op (DB)")
@@ -1521,7 +1529,12 @@ open class SubscriptionStateMachineV2 : KoinComponent {
}
}
- open fun getCurrentState(): SubscriptionState = stateMachine.getCurrentState()
+ // NOTE: intentionally NOT named `getCurrentState()` — the JVM signature would collide
+ // with the `currentState` property getter above (same name + params, return type only
+ // differs). ByteBuddy/MockK cannot proxy such colliding pairs, which broke every unit
+ // test that stubbed this class (the real getter ran on mocks whose `stateMachine` field
+ // is null, throwing NPE).
+ open fun currentMachineState(): SubscriptionState = stateMachine.getCurrentState()
open fun getSubscriptionData(): SubscriptionData? = stateMachine.getCurrentData()
open fun canMakePurchase(): Boolean = stateMachine.getCurrentState().canMakePurchase
open fun hasValidSubscription(): Boolean = stateMachine.getCurrentState().hasValidSubscription
diff --git a/app/src/main/java/com/celzero/bravedns/rpnproxy/SubscriptionUiStateResolver.kt b/app/src/main/java/com/celzero/bravedns/rpnproxy/SubscriptionUiStateResolver.kt
new file mode 100644
index 0000000000..7b004a62da
--- /dev/null
+++ b/app/src/main/java/com/celzero/bravedns/rpnproxy/SubscriptionUiStateResolver.kt
@@ -0,0 +1,74 @@
+/*
+ * 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.rpnproxy
+
+import com.celzero.bravedns.database.SubscriptionStatus
+
+/**
+ * Resolves the raw [SubscriptionStateMachineV2.SubscriptionState] plus the persisted
+ * [SubscriptionStatus] row into a single UI-facing model shared by
+ * [com.celzero.bravedns.ui.fragment.RethinkPlusDashboardFragment] and
+ * [com.celzero.bravedns.ui.fragment.RethinkPlusManagePurchaseFragment] so that both
+ * screens render identical "No Purchase" behaviour.
+ */
+object SubscriptionUiStateResolver {
+
+ sealed class PurchaseUiModel {
+ /** State not yet resolved (Uninitialized / ServerAckPending); suppress first paint. */
+ object Loading : PurchaseUiModel()
+
+ /** Never purchased: Initial state with no persisted SubscriptionStatus. */
+ object NoPurchase : PurchaseUiModel()
+
+ /** Entitlement gone (Expired / Revoked); historical purchase data may exist. */
+ data class Lapsed(val sub: SubscriptionStatus?) : PurchaseUiModel()
+
+ /**
+ * Valid or transient states (Active, Grace, Paused, OnHold, Cancelled,
+ * PurchaseInitiated, PurchasePending, Error, or Initial with a stale DB row);
+ * render normal UI with status-chip styling driven by the raw state.
+ */
+ data class Valid(
+ val state: SubscriptionStateMachineV2.SubscriptionState,
+ val sub: SubscriptionStatus?
+ ) : PurchaseUiModel()
+ }
+
+ fun resolve(
+ state: SubscriptionStateMachineV2.SubscriptionState,
+ sub: SubscriptionStatus?
+ ): PurchaseUiModel {
+ return when (state) {
+ is SubscriptionStateMachineV2.SubscriptionState.Uninitialized,
+ is SubscriptionStateMachineV2.SubscriptionState.ServerAckPending ->
+ PurchaseUiModel.Loading
+
+ is SubscriptionStateMachineV2.SubscriptionState.Initial ->
+ if (sub == null) PurchaseUiModel.NoPurchase else valid(state, sub)
+
+ is SubscriptionStateMachineV2.SubscriptionState.Expired,
+ is SubscriptionStateMachineV2.SubscriptionState.Revoked ->
+ PurchaseUiModel.Lapsed(sub)
+
+ else -> valid(state, sub)
+ }
+ }
+
+ private fun valid(
+ state: SubscriptionStateMachineV2.SubscriptionState,
+ sub: SubscriptionStatus?
+ ): PurchaseUiModel = PurchaseUiModel.Valid(state, sub)
+}
diff --git a/app/src/main/java/com/celzero/bravedns/scheduler/BootStartWorker.kt b/app/src/main/java/com/celzero/bravedns/scheduler/BootStartWorker.kt
index 2f8757b780..31d0978f08 100644
--- a/app/src/main/java/com/celzero/bravedns/scheduler/BootStartWorker.kt
+++ b/app/src/main/java/com/celzero/bravedns/scheduler/BootStartWorker.kt
@@ -79,6 +79,12 @@ class BootStartWorker(context: Context, params: WorkerParameters) :
} catch (_: NullPointerException) {
Logger.w(LOG_TAG_VPN, "device does not support system-wide VPN mode")
return Result.success()
+ } catch (e: IllegalStateException) {
+ // VpnService.prepare() throws IllegalStateException("Unavailable in lockdown
+ // mode") when another VPN app is set as Always-on VPN with "Block connections
+ // without VPN" enabled. Skip auto-start in that case.
+ Logger.w(LOG_TAG_VPN, "vpn unavailable: in lockdown mode, skipping boot start", e)
+ return Result.success()
}
if (prepareVpnIntent != null) {
diff --git a/app/src/main/java/com/celzero/bravedns/scheduler/EnhancedBugReport.kt b/app/src/main/java/com/celzero/bravedns/scheduler/EnhancedBugReport.kt
index ed094c4d29..f4bc037509 100644
--- a/app/src/main/java/com/celzero/bravedns/scheduler/EnhancedBugReport.kt
+++ b/app/src/main/java/com/celzero/bravedns/scheduler/EnhancedBugReport.kt
@@ -24,6 +24,7 @@ import androidx.annotation.RequiresApi
import com.celzero.bravedns.scheduler.EnhancedBugReport.MAX_TOTAL_FILES
import com.celzero.bravedns.service.PersistentState
import com.celzero.bravedns.util.Constants
+import com.celzero.bravedns.util.ExceptionParser
import com.celzero.bravedns.util.FirebaseErrorReporting
import com.celzero.bravedns.util.Utilities
import org.koin.core.component.KoinComponent
@@ -60,17 +61,9 @@ object EnhancedBugReport : KoinComponent {
// When Firebase is OFF, files accumulate up to this limit before the oldest are deleted.
private const val MAX_TOTAL_FILES = 20
- private const val MAX_BYTES = 64 * 1024
- private const val MAX_EXCEPTION_PREVIEW_CHARS = 2 * 1024
- private const val MAX_FAILURE_MESSAGE_CHARS = 1024
-
- private val JVM_EXCEPTION_HEADER =
- Regex(
- """^(?:Exception in thread "[^"]+"\s+)?(?:Caused by:\s*)?[\w.$]+(?:Exception|Error|Throwable)(?::.*)?$"""
- )
- private val JVM_FRAME = Regex("""^\s*at\s+([^\s(]+)\(([^)]*)\)\s*$""")
- private val GO_LOCATION = Regex("""^\s*(.+\.go):(\d+)(?:\s+.*)?$""")
-
+ // Crashlytics log() is a 64 KB ring buffer; keep each log entry comfortably small so
+ // no chunk of the raw captured trace is truncated or evicted mid-write.
+ private const val LOG_CHUNK_CHARS = 4 * 1024
private val persistentState by inject()
/**
@@ -313,25 +306,58 @@ object EnhancedBugReport : KoinComponent {
}
/**
- * Reads [file] content, builds a synthetic exception and records it as a non-fatal event
- * in Firebase Crashlytics. Content > 2 KB is also chunked into log() calls so the full
- * crash text is available in the Crashlytics log tab.
+ * Reads [file] content and reports it to Firebase Crashlytics as a non-fatal event.
+ *
+ * The file content is parsed by [ExceptionParser]; when Go/JVM stack frames are found,
+ * a synthetic imported exception carrying the *captured* frames is recorded via
+ * [FirebaseErrorReporting.recordException] so Crashlytics shows the real crash origin
+ * instead of this reporting method. The complete raw content is additionally shipped
+ * via [FirebaseErrorReporting.log] so information not representable as
+ * [StackTraceElement]s (Caused by / Suppressed / goroutine headers / corrupt lines)
+ * is still available in the Crashlytics log tab.
+ *
+ * When nothing could be parsed (e.g. golog_ files or unrecognised content), falls back
+ * to the legacy behaviour: a synthetic [RuntimeException] with a 2 KB message preview
+ * plus the full content in 8 KB log chunks.
*
* Returns true if the record call did not throw.
*/
private fun sendFileToFirebase(file: File): Boolean {
return try {
- val content = readTruncatedContent(file)
- val type = reportType(file.name)
+ // read the complete original content; never truncate before parsing.
+ val content = file.readText()
+ val type = when {
+ file.name.startsWith(PREFIX_GO_CRASH) -> "GoCrash"
+ file.name.startsWith(PREFIX_GO_LOG) -> "GoLog"
+ file.name.startsWith(PREFIX_KOTLIN) -> "KotlinCrash"
+ else -> "CrashLog"
+ }
Logger.d(LOG_TAG_BUG_REPORT, "err-rpting: sending $type ${file.name} (${content.length} chars)")
- val ex = buildReportException(file.name, content)
- // log rest content in 8 KB chunks so nothing is lost.
- content.chunked(8 * 1024).forEachIndexed { idx, chunk ->
- FirebaseErrorReporting.log("[$type][${file.name}][$idx] $chunk")
+ val parsed = ExceptionParser.parse(content)
+ if (parsed.frames.isEmpty()) {
+ // nothing parsable as a stack trace: keep the legacy reporting behaviour.
+ val messagePreview = content.take(2 * 1024)
+ val ex = RuntimeException("[$type] ${file.name}\n$messagePreview")
+
+ // log rest content in 8 KB chunks so nothing is lost.
+ content.chunked(8 * 1024).forEachIndexed { idx, chunk ->
+ FirebaseErrorReporting.log("[$type][${file.name}][$idx] $chunk")
+ }
+ // note: call the log before exception and then record exception
+ FirebaseErrorReporting.recordException(ex)
+ } else {
+ // log the captured type + full raw trace first, then record the reconstructed
+ // exception so the log lines appear right above it in the Crashlytics log tab.
+ // (Crashlytics log is a 64 KB ring buffer; chunk to keep each entry intact.)
+ FirebaseErrorReporting.log("== captured ${parsed.type.name.lowercase()} ==")
+ content.chunked(LOG_CHUNK_CHARS).forEachIndexed { idx, chunk ->
+ FirebaseErrorReporting.log("[$type][${file.name}][$idx] $chunk")
+ }
+ FirebaseErrorReporting.recordException(
+ parsed.toThrowable(context = "$type ${file.name}")
+ )
}
- // note: call the log before exception and then record exception
- FirebaseErrorReporting.recordException(ex)
Log.d(LOG_TAG_BUG_REPORT, "err-rpting: sent $type ${file.name} (${content.length} chars)")
true
} catch (e: Exception) {
@@ -340,143 +366,6 @@ object EnhancedBugReport : KoinComponent {
}
}
- /** Builds the same exception sent to Crashlytics, restoring frames when the format permits. */
- internal fun buildReportException(fileName: String, content: String): RuntimeException {
- val type = reportType(fileName)
- val frames = when {
- fileName.startsWith(PREFIX_KOTLIN) -> parseJvmFrames(content)
- fileName.startsWith(PREFIX_GO_CRASH) -> parseGoFrames(content)
- else -> emptyList()
- }
- val failure = when {
- fileName.startsWith(PREFIX_KOTLIN) -> findJvmExceptionHeader(content)
- fileName.startsWith(PREFIX_GO_CRASH) -> findGoFailureHeader(content)
- else -> null
- }
- val message = if (failure != null) {
- "[$type] $fileName: ${failure.take(MAX_FAILURE_MESSAGE_CHARS)}"
- } else {
- // Keep the existing bounded preview for malformed tombstones and non-crash logs.
- "[$type] $fileName\n${content.take(MAX_EXCEPTION_PREVIEW_CHARS)}"
- }
- return RuntimeException(message).also { exception ->
- if (frames.isNotEmpty()) {
- exception.stackTrace = frames.toTypedArray()
- }
- }
- }
-
- private fun reportType(fileName: String): String {
- return when {
- fileName.startsWith(PREFIX_GO_CRASH) -> "GoCrash"
- fileName.startsWith(PREFIX_GO_LOG) -> "GoLog"
- fileName.startsWith(PREFIX_KOTLIN) -> "KotlinCrash"
- else -> "CrashLog"
- }
- }
-
- private fun findJvmExceptionHeader(content: String): String? {
- return content.lineSequence()
- .map { it.trim() }
- .firstOrNull { JVM_EXCEPTION_HEADER.matches(it) }
- }
-
- private fun findGoFailureHeader(content: String): String? {
- return content.lineSequence()
- .map { it.trim() }
- .firstOrNull { it.startsWith("panic:") || it.startsWith("fatal error:") }
- }
-
- private fun parseJvmFrames(content: String): List {
- return content.lineSequence().mapNotNull { line ->
- val match = JVM_FRAME.matchEntire(line) ?: return@mapNotNull null
- val classAndMethod = match.groupValues[1]
- val separator = classAndMethod.lastIndexOf('.')
- if (separator <= 0 || separator == classAndMethod.lastIndex) {
- return@mapNotNull null
- }
-
- val className = classAndMethod.substring(0, separator)
- val methodName = classAndMethod.substring(separator + 1)
- val location = match.groupValues[2]
- when (location) {
- "Native Method" -> StackTraceElement(className, methodName, null, -2)
- "Unknown Source" -> StackTraceElement(className, methodName, null, -1)
- else -> {
- val lineSeparator = location.lastIndexOf(':')
- val lineNumber = if (lineSeparator >= 0) {
- location.substring(lineSeparator + 1).toIntOrNull()
- } else {
- null
- }
- val sourceFile = if (lineNumber != null) {
- location.substring(0, lineSeparator)
- } else {
- location
- }
- if (sourceFile.isBlank()) {
- null
- } else {
- StackTraceElement(className, methodName, sourceFile, lineNumber ?: -1)
- }
- }
- }
- }.toList()
- }
-
- private fun parseGoFrames(content: String): List {
- val lines = content.lines()
- return lines.mapIndexedNotNull { index, line ->
- if (index == lines.lastIndex) return@mapIndexedNotNull null
- val functionName = parseGoFunctionName(line) ?: return@mapIndexedNotNull null
- val location = GO_LOCATION.matchEntire(lines[index + 1]) ?: return@mapIndexedNotNull null
- val separator = functionName.lastIndexOf('.')
- if (separator <= 0 || separator == functionName.lastIndex) {
- return@mapIndexedNotNull null
- }
-
- val filePath = location.groupValues[1]
- val lineNumber = location.groupValues[2].toIntOrNull() ?: -1
- StackTraceElement(
- functionName.substring(0, separator),
- functionName.substring(separator + 1),
- filePath.substringAfterLast('/'),
- lineNumber
- )
- }
- }
-
- private fun parseGoFunctionName(line: String): String? {
- val trimmed = line.trim()
- if (trimmed.isEmpty()) return null
- if (trimmed.startsWith("created by ")) {
- return trimmed.removePrefix("created by ").substringBefore(" in goroutine").trim()
- .takeIf { it.isNotEmpty() }
- }
- if (line.firstOrNull()?.isWhitespace() == true || !trimmed.contains('.')) return null
-
- val receiverEnd = trimmed.lastIndexOf(").")
- val argumentsStart = if (receiverEnd >= 0) {
- trimmed.indexOf('(', receiverEnd + 2)
- } else {
- trimmed.indexOf('(')
- }
- if (argumentsStart < 0) return null
- return trimmed.substring(0, argumentsStart)
- .takeIf { it.isNotEmpty() }
- }
-
- private fun readTruncatedContent(file: File): String {
- file.inputStream().buffered().use { input ->
- val buffer = ByteArray(MAX_BYTES)
- val bytesRead = input.read(buffer)
- if (bytesRead <= 0) return ""
-
- return buffer.copyOf(bytesRead)
- .toString(Charsets.UTF_8)
- }
- }
-
/**
* Deletes the oldest files until the total count is ≤ [MAX_TOTAL_FILES].
* [justWritten] is never deleted (it is the file being actively written by a reader).
diff --git a/app/src/main/java/com/celzero/bravedns/scheduler/RpnProxyUpdateWorker.kt b/app/src/main/java/com/celzero/bravedns/scheduler/RpnProxyUpdateWorker.kt
index 9bf9a475c8..2b139f97a4 100644
--- a/app/src/main/java/com/celzero/bravedns/scheduler/RpnProxyUpdateWorker.kt
+++ b/app/src/main/java/com/celzero/bravedns/scheduler/RpnProxyUpdateWorker.kt
@@ -30,6 +30,7 @@ import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.celzero.bravedns.iab.BillingBackendClient
+import com.celzero.bravedns.iab.DeviceRegistrationGuard
import com.celzero.bravedns.iab.InAppBillingHandler
import com.celzero.bravedns.iab.PurchaseDetail
import com.celzero.bravedns.iab.RegisterDeviceResult
@@ -226,6 +227,10 @@ class RpnProxyUpdateWorker(
*/
private suspend fun checkAndRegisterDeviceIfNeeded() {
val mname = "checkAndRegisterDeviceIfNeeded"
+ // Single-flight: SubscriptionCheckWorker (enqueued from doWork) runs the same
+ // check concurrently. Coalesce overlapping runs so concurrent reconciles cannot
+ // race into minting duplicate DIDs (two POST /d/reg at the same second).
+ if (!DeviceRegistrationGuard.tryBegin(mname)) return
try {
val storedAccountId = billingBackendClient.getAccountId()
val storedDeviceId = billingBackendClient.getDeviceId()
@@ -274,6 +279,8 @@ class RpnProxyUpdateWorker(
} catch (e: Exception) {
// failure here must not block purchase validation
Logger.e(LOG_IAB, "$TAG; $mname: error (non-fatal): ${e.message}", e)
+ } finally {
+ DeviceRegistrationGuard.end(mname)
}
}
diff --git a/app/src/main/java/com/celzero/bravedns/scheduler/WorkScheduler.kt b/app/src/main/java/com/celzero/bravedns/scheduler/WorkScheduler.kt
index e71b70557f..9e8183867f 100644
--- a/app/src/main/java/com/celzero/bravedns/scheduler/WorkScheduler.kt
+++ b/app/src/main/java/com/celzero/bravedns/scheduler/WorkScheduler.kt
@@ -54,14 +54,13 @@ class WorkScheduler(val context: Context, val persistentState: PersistentState)
val statuses: ListenableFuture> = instance.getWorkInfosByTag(tag)
Logger.i(LOG_TAG_SCHEDULER, "Job $tag already running check")
return try {
- var running = false
val workInfos = statuses.get()
if (workInfos.isNullOrEmpty()) return false
- for (workStatus in workInfos) {
- running = workStatus.state == WorkInfo.State.RUNNING
- }
+ // any-of semantics: finished WorkInfos linger until pruned, so the state of
+ // a *single* (eg, last) entry must not shadow an actually-running one.
+ val running = workInfos.any { it.state == WorkInfo.State.RUNNING }
Logger.i(LOG_TAG_SCHEDULER, "Job $tag already running? $running")
running
} catch (e: ExecutionException) {
@@ -80,18 +79,19 @@ class WorkScheduler(val context: Context, val persistentState: PersistentState)
val statuses: ListenableFuture> = instance.getWorkInfosByTag(tag)
Logger.i(LOG_TAG_SCHEDULER, "Job $tag already scheduled check")
return try {
- var running = false
val workInfos = statuses.get()
if (workInfos.isNullOrEmpty()) return false
- for (workStatus in workInfos) {
- running =
- workStatus.state == WorkInfo.State.RUNNING ||
- workStatus.state == WorkInfo.State.ENQUEUED
+ // any-of semantics; BLOCKED counts because chained workers stay BLOCKED
+ // until their predecessor finishes, yet the chain is very much scheduled.
+ val scheduled = workInfos.any {
+ it.state == WorkInfo.State.RUNNING ||
+ it.state == WorkInfo.State.ENQUEUED ||
+ it.state == WorkInfo.State.BLOCKED
}
- Logger.i(LOG_TAG_SCHEDULER, "Job $tag already scheduled? $running")
- running
+ Logger.i(LOG_TAG_SCHEDULER, "Job $tag already scheduled? $scheduled")
+ scheduled
} catch (e: ExecutionException) {
Logger.e(LOG_TAG_SCHEDULER, "error on status check ${e.message}", e)
false
diff --git a/app/src/main/java/com/celzero/bravedns/service/BraveTileService.kt b/app/src/main/java/com/celzero/bravedns/service/BraveTileService.kt
index 663d60ab54..4e87a9e1fd 100644
--- a/app/src/main/java/com/celzero/bravedns/service/BraveTileService.kt
+++ b/app/src/main/java/com/celzero/bravedns/service/BraveTileService.kt
@@ -43,39 +43,45 @@ class BraveTileService : TileService(), KoinComponent {
// generate a new function-reference object per `::` expression — meaning
// removeObserver(this::updateTile) does NOT find the observer previously
// registered with observeForever(this::updateTile), and the observer leaks.
- private val tileObserver = Observer { enabled -> updateTile(enabled) }
+ private val tileObserver = Observer { updateTile() }
override fun onCreate() {
super.onCreate()
+ Logger.v(Logger.LOG_TAG_VPN, "Tile: on create")
+ }
+
+ // The tile is only bound between onStartListening() and onStopListening()
+ override fun onStartListening() {
+ super.onStartListening()
try {
persistentState.vpnEnabledLiveData.observeForever(tileObserver)
} catch (e: Exception) {
Logger.w(Logger.LOG_TAG_UI, "Tile: err in observing VPN state", e)
}
+ updateTile()
}
- private fun updateTile(enabled: Boolean) {
- qsTile?.apply {
- state = if (enabled) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE
- updateTile()
+ override fun onStopListening() {
+ try {
+ persistentState.vpnEnabledLiveData.removeObserver(tileObserver)
+ } catch (e: Exception) {
+ Logger.w(Logger.LOG_TAG_UI, "Tile: err in removing observer", e)
}
+ super.onStopListening()
}
- override fun onStartListening() {
- super.onStartListening()
- // Just seed the current value; the observer is already attached in onCreate
- // and will fire on subsequent changes. Re-registering here would either
- // stack a second observer (leak) or be a no-op duplicate.
- updateTile(persistentState.getVpnEnabled())
+ // get the tile state from the actual vpn state, not from the persistent state alone
+ private fun isVpnActive(): Boolean {
+ val state = VpnController.state()
+ return state.activationRequested && (state.on || state.connectionState != null)
}
- override fun onDestroy() {
- try {
- persistentState.vpnEnabledLiveData.removeObserver(tileObserver)
- } catch (e: Exception) {
- Logger.w(Logger.LOG_TAG_UI, "Tile: err in removing observer", e)
+ private fun updateTile() {
+ val enabled = isVpnActive()
+ qsTile?.apply {
+ state = if (enabled) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE
+ updateTile()
}
- super.onDestroy()
}
private fun isAppRunningOnTv(): Boolean {
@@ -96,35 +102,63 @@ class BraveTileService : TileService(), KoinComponent {
}
}
+ private fun isVpnPrepared(): Boolean {
+ return try {
+ VpnService.prepare(this) == null
+ } catch (e: NullPointerException) {
+ Logger.w(Logger.LOG_TAG_VPN, "Tile: device does not support system-wide VPN mode", e)
+ false
+ } catch (e: IllegalStateException) {
+ // VpnService.prepare() throws IllegalStateException("Unavailable in lockdown mode")
+ // when another VPN app is set as Always-on VPN with "Block connections without VPN"
+ // enabled. See ConnectivityService.throwIfLockdownEnabled(). Fall through to the
+ // else-branch (opens the app) so the user can resolve it there.
+ Logger.w(Logger.LOG_TAG_VPN, "Tile: vpn unavailable, in lockdown mode", e)
+ false
+ } catch (e: Exception) {
+ Logger.w(Logger.LOG_TAG_VPN, "Tile: err while preparing vpn service", e)
+ false
+ }
+ }
+
override fun onClick() {
super.onClick()
// do not start or stop VPN if app lock is enabled
if (VpnController.state().activationRequested && !isAppLockEnabled()) {
- VpnController.stop("tile",this)
- } else if (VpnService.prepare(this) == null && !isAppLockEnabled()) {
+ if (VpnController.isAlwaysOn(this)) {
+ Logger.i(Logger.LOG_TAG_VPN, "Tile: vpn is always-on, opening app instead of stop")
+ openApp()
+ } else {
+ VpnController.stop("tile", this)
+ }
+ } else if (isVpnPrepared() && !isAppLockEnabled()) {
// Start VPN service when VPN permission has been granted
VpnController.start(this)
} else {
// open the app to handle the VPN start or stop
- val intent = Intent(this, AppLockActivity::class.java)
- val pendingIntent = PendingIntent.getActivity(this, 0, intent,
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
- try {
- if (Utilities.isAtleastU()) {
- startActivityAndCollapse(pendingIntent)
- } else {
- // For older versions, convert PendingIntent to Intent and start the activity
- val newIntent = Intent(intent).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- startActivity(newIntent)
- }
- } catch (e: UnsupportedOperationException) {
- // starting activity from TileService using an Intent is not allowed
- // use PendingIntent instead
- Logger.w(Logger.LOG_TAG_UI, "Tile: unsupported operation, use send()", e)
- pendingIntent.send()
- } catch (e: Exception) {
- Logger.w(Logger.LOG_TAG_UI, "Tile: err in starting activity", e)
+ openApp()
+ }
+ }
+
+ private fun openApp() {
+ val intent = Intent(this, AppLockActivity::class.java)
+ val pendingIntent = PendingIntent.getActivity(this, 0, intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
+ try {
+ if (Utilities.isAtleastU()) {
+ startActivityAndCollapse(pendingIntent)
+ } else {
+ // For older versions, convert PendingIntent to Intent and start the activity
+ val newIntent = Intent(intent).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ startActivity(newIntent)
}
+ } catch (e: UnsupportedOperationException) {
+ // starting activity from TileService using an Intent is not allowed
+ // use PendingIntent instead
+ Logger.w(Logger.LOG_TAG_UI, "Tile: unsupported operation, use send()", e)
+ pendingIntent.send()
+ } catch (e: Exception) {
+ Logger.w(Logger.LOG_TAG_UI, "Tile: err in starting activity", e)
}
}
}
diff --git a/app/src/main/java/com/celzero/bravedns/service/BraveVPNService.kt b/app/src/main/java/com/celzero/bravedns/service/BraveVPNService.kt
index ac8fd6738b..abb4345c5a 100644
--- a/app/src/main/java/com/celzero/bravedns/service/BraveVPNService.kt
+++ b/app/src/main/java/com/celzero/bravedns/service/BraveVPNService.kt
@@ -153,7 +153,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
+import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
+import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch
@@ -284,6 +286,7 @@ class BraveVPNService : VpnService(), ConnectionMonitor.NetworkListener, Network
private val persistentState by inject()
private val rdb by inject()
private val netLogTracker by inject()
+ private val logActivityAggregator by inject()
@Volatile
private var isAccessibilityServiceFunctional: Boolean = false
@@ -422,7 +425,7 @@ class BraveVPNService : VpnService(), ConnectionMonitor.NetworkListener, Network
// belongs to, else bind to the available network
val net = if (KnownPorts.isDns(destPort)) curnet?.dnsServers?.get(destAddr) else null
if (net != null) {
- val ok = bindToNw(net, pfd, fid)
+ val ok = bindToNw(net, pfd, fid, addrPort)
if (!ok) {
Logger.e(LOG_TAG_VPN, "bind failed, who: $who, addr: $addrPort, fd: $fid, handle: ${net.networkHandle}, netid:${netid(net.networkHandle)}")
} else {
@@ -440,7 +443,7 @@ class BraveVPNService : VpnService(), ConnectionMonitor.NetworkListener, Network
}
nws.forEach {
- val ok = bindToNw(it.network, pfd, fid)
+ val ok = bindToNw(it.network, pfd, fid, addrPort)
Logger.vv(LOG_TAG_VPN, "bindAny: bindToNw handle: ${it.network.networkHandle}")
if (ok) {
logd("bind: nw, who: $who, addr: $addrPort, fd: $fid, handle: ${it.network.networkHandle}, netid:${netid(it.network.networkHandle)}")
@@ -466,7 +469,7 @@ class BraveVPNService : VpnService(), ConnectionMonitor.NetworkListener, Network
var pfd: ParcelFileDescriptor? = null
try {
pfd = ParcelFileDescriptor.adoptFd(fid.toInt())
- return bindToNw(nw, pfd, fid)
+ return bindToNw(nw, pfd, fid, "conn-checks")
} catch (e: Exception) {
Logger.i(LOG_TAG_VPN, "err bindToNwForConnectivityChecks, ${e.message}")
} finally {
@@ -488,15 +491,15 @@ class BraveVPNService : VpnService(), ConnectionMonitor.NetworkListener, Network
return vpnAdapter?.getPlusTransportById(transportId)
}
- private fun bindToNw(net: Network, pfd: ParcelFileDescriptor, fid: Long): Boolean {
+ private fun bindToNw(net: Network, pfd: ParcelFileDescriptor, fid: Long, addrPort: String): Boolean {
val res = try {
net.bindSocket(pfd.fileDescriptor)
true
} catch (e: IOException) {
- Logger.e(LOG_TAG_VPN, "err bindToNw(nw: ${net.networkHandle}, netid: ${netid(net.networkHandle)}, fid: $fid, ${e.message}, $e")
+ Logger.e(LOG_TAG_VPN, "err bindToNw(nw: ${net.networkHandle}, addrPort: $addrPort, netid: ${netid(net.networkHandle)}, fid: $fid, ${e.message}, $e")
false
}
- Logger.vv(LOG_TAG_VPN, "bindToNw: nw: ${net.networkHandle}, fid: $fid, success: $res")
+ Logger.vv(LOG_TAG_VPN, "bindToNw: addrPort: $addrPort, nw: ${net.networkHandle}, fid: $fid, success: $res")
return res
}
@@ -721,6 +724,14 @@ class BraveVPNService : VpnService(), ConnectionMonitor.NetworkListener, Network
netLogTracker.restart(vpnScope)
}
+ // Warm the activity-wall cache (trailing 24 hours of 10-minute
+ // buckets) from the databases so the heatmap is ready immediately
+ // at VPN start.
+ io("logActivityHistory") {
+ logActivityAggregator.restoreFromDatabase()
+ }
+
+
notificationManager = this.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
activityManager = this.getSystemService(ACTIVITY_SERVICE) as ActivityManager
accessibilityManager = this.getSystemService(ACCESSIBILITY_SERVICE) as AccessibilityManager
@@ -1125,34 +1136,38 @@ class BraveVPNService : VpnService(), ConnectionMonitor.NetworkListener, Network
VpnController.onConnectionStateChanged(State.NEW)
- ui {
- // Initialize the value whenever the vpn is started.
- accessibilityHearbeatTimestamp = INIT_TIME_MS
-
- // startForeground should always be called within 5 secs of onStartCommand invocation
- // https://developer.android.com/guide/components/fg-service-types
- // to log the exception type, wrap the call in different methods based on the API level
- // TODO: can remove multiple startForegroundService calls if we decide to remove
- // multiple catch blocks for API 31 and above
- if (isAtleastU()) {
- var ok = startForegroundService(FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED)
- if (!ok) {
- Logger.w(LOG_TAG_VPN, "start service failed, retrying with connected device")
- ok = startForegroundService(FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE)
- }
- if (!ok) {
- Logger.w(LOG_TAG_VPN, "start service failed, stopping service")
- signalStopService("startFg1", userInitiated = false) // notify and stop
- return@ui
- }
- } else {
- val ok = startForegroundService()
- if (!ok) {
- Logger.w(LOG_TAG_VPN, "start service failed ( > U ), stopping service")
- signalStopService("startFg2", userInitiated = false) // notify and stop
- return@ui
- }
+ // Initialize the value whenever the vpn is started.
+ accessibilityHearbeatTimestamp = INIT_TIME_MS
+
+ // startForeground should always be called within 5 secs of onStartCommand invocation
+ // https://developer.android.com/guide/components/fg-service-types
+ // Call startForeground synchronously (NOT via ui{}): the system validates the foreground
+ // notification asynchronously after startForeground() returns, and crashes the process
+ // with CannotPostForegroundServiceNotificationException ("Bad notification for
+ // startForeground") if validation fails.
+ // TODO: can remove multiple startForegroundService calls if we decide to remove
+ // multiple catch blocks for API 31 and above
+ if (isAtleastU()) {
+ var ok = startForegroundService(FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED)
+ if (!ok) {
+ Logger.w(LOG_TAG_VPN, "start service failed, retrying with connected device")
+ ok = startForegroundService(FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE)
+ }
+ if (!ok) {
+ Logger.w(LOG_TAG_VPN, "start service failed, stopping service")
+ signalStopService("startFg1", userInitiated = false) // notify and stop
+ return START_STICKY
+ }
+ } else {
+ val ok = startForegroundService()
+ if (!ok) {
+ Logger.w(LOG_TAG_VPN, "start service failed ( > U ), stopping service")
+ signalStopService("startFg2", userInitiated = false) // notify and stop
+ return START_STICKY
}
+ }
+
+ ui {
// this should always be set before ConnectionMonitor is init-d
// see restartVpn and updateTun which expect this to be the case
persistentState.setVpnEnabled(true)
@@ -1532,9 +1547,7 @@ class BraveVPNService : VpnService(), ConnectionMonitor.NetworkListener, Network
}
AppConfig.DnsType.SMART_DNS -> {
- // no need to add multiple DoH as smart dns as it is expected to be
- // added by the vpn adapter while starting, but add it if it is missing
- if(getDnsStatus(Backend.Plus) == null) addTransport()
+ vpnAdapter?.setPlusStrategy()
}
AppConfig.DnsType.DOT -> {
@@ -4639,24 +4652,44 @@ class BraveVPNService : VpnService(), ConnectionMonitor.NetworkListener, Network
@RequiresApi(VERSION_CODES.Q)
private fun initializeBubble() {
- try {
- // Request bubble. Bubbles are always backed by a notification, but we suppress the
- // shade entry via BubbleMetadata#setSuppressNotification(true).
- val eligible = BubbleHelper.showBubble(this, persistentState)
- Logger.i(TAG, "Bubble notification posted (eligible=$eligible)")
-
- // If not eligible, do not install observers / update loops.
- // Do not post any fallback notification (bubble-only UX).
- if (!eligible) {
- unobserveBubbleBlockedConns()
- return
- }
+ io("bubbleInit") {
+ try {
+ // ShortcutManagerCompat / NotificationManager calls inside showBubble() are
+ // synchronous binder IPC to system_server and can stall for seconds when it
+ // is busy; run them off the main thread to avoid ANRs during onCreate().
+ // Request bubble. Bubbles are always backed by a notification, but we suppress the
+ // shade entry via BubbleMetadata#setSuppressNotification(true).
+ val eligible = BubbleHelper.showBubble(this@BraveVPNService, persistentState)
+ Logger.i(TAG, "Bubble notification posted (eligible=$eligible)")
+
+ // If the coroutine was cancelled while showBubble() was executing (e.g. VPN
+ // stopped), dismiss the just-posted bubble and rethrow; do not install
+ // observers on a torn-down service.
+ currentCoroutineContext().ensureActive()
+
+ withContext(Dispatchers.Main) {
+ // If not eligible, do not install observers / update loops.
+ // Do not post any fallback notification (bubble-only UX).
+ if (!eligible) {
+ unobserveBubbleBlockedConns()
+ return@withContext
+ }
- blockedConnsObserver = makeFirewallBlockedConnsObserver()
- connTrackRepository.getBlockedConnectionsCountLiveData().observeForever(blockedConnsObserver)
- } catch (e: Exception) {
- Logger.e(TAG, "Bubble init failed: ${e.message}", e)
- stopSelf()
+ blockedConnsObserver = makeFirewallBlockedConnsObserver()
+ connTrackRepository.getBlockedConnectionsCountLiveData()
+ .observeForever(blockedConnsObserver)
+ }
+ } catch (e: CancellationException) {
+ Logger.w(TAG, "Bubble init cancelled; dismissing bubble: ${e.message}")
+ try {
+ BubbleHelper.dismissBubble(this@BraveVPNService)
+ } catch (ex: Exception) {
+ Logger.w(TAG, "err dismissing bubble: ${ex.message}")
+ }
+ throw e
+ } catch (e: Exception) {
+ Logger.e(TAG, "Bubble init failed: ${e.message}", e)
+ }
}
}
private var lastBlockedCount = -1
diff --git a/app/src/main/java/com/celzero/bravedns/service/DnsLogTracker.kt b/app/src/main/java/com/celzero/bravedns/service/DnsLogTracker.kt
index 2dfadf69dc..fb2bbbe7ea 100644
--- a/app/src/main/java/com/celzero/bravedns/service/DnsLogTracker.kt
+++ b/app/src/main/java/com/celzero/bravedns/service/DnsLogTracker.kt
@@ -57,6 +57,46 @@ internal constructor(
val DNS_TTL_GRACE_SEC = TimeUnit.MINUTES.toSeconds(5L)
private const val RDATA_MAX_LENGTH = 100
private const val EMPTY_RESPONSE = "--"
+
+ /**
+ * Arrival-time blocked classification for a dns answer, derived from the
+ * raw [com.celzero.firestack.backend.DNSSummary] fields. Mirrors the
+ * isBlocked assignments made by [makeDnsLogObj] (including the
+ * COMPLETE+ip override of an earlier BlockAll marker) so callers can
+ * aggregate at log-arrival time without duplicating or re-deriving this
+ * logic. makeDnsLogObj remains the persistence-side authority.
+ */
+ fun isBlockedDnsAnswer(
+ transportId: String,
+ statusCode: Int,
+ response: String,
+ qType: Long,
+ blocklists: String,
+ upstreamBlock: Boolean
+ ): Boolean {
+ var blocked = false
+
+ // mark the query as blocked if the transport id is BlockAll/Block;
+ // no need to check for blocklist as it is already marked as blocked
+ if (transportId == Backend.BlockAll || transportId == Backend.Block) {
+ blocked = true
+ }
+
+ if (Transaction.Status.fromId(statusCode) == Transaction.Status.COMPLETE &&
+ ResourceRecordTypes.mayContainIP(qType.toInt())
+ ) {
+ val destination = normalizeIp(response.split(",").firstOrNull())
+ if (destination != null) {
+ // overwrites any earlier BlockAll marker, matching makeDnsLogObj
+ blocked = destination.hostAddress == UNSPECIFIED_IP_IPV4 ||
+ destination.hostAddress == UNSPECIFIED_IP_IPV6
+ } else if (response == EMPTY_RESPONSE && (blocklists.isNotEmpty() || upstreamBlock)) {
+ blocked = true
+ }
+ }
+
+ return blocked
+ }
}
fun processOnResponse(summary: DNSSummary): Transaction {
diff --git a/app/src/main/java/com/celzero/bravedns/service/DomainRulesManager.kt b/app/src/main/java/com/celzero/bravedns/service/DomainRulesManager.kt
index dc2aafd538..cc7a121321 100644
--- a/app/src/main/java/com/celzero/bravedns/service/DomainRulesManager.kt
+++ b/app/src/main/java/com/celzero/bravedns/service/DomainRulesManager.kt
@@ -42,12 +42,12 @@ object DomainRulesManager : KoinComponent {
private val db by inject()
- private val trie: RadixTree = Backend.newRadixTree()
+ private val trie: RadixTree by lazy { Backend.newRadixTree() }
// fixme: find a better way to handle trusted domains without using two data structures
// map to store the trusted domains with set of uids
private val trustedMap = ConcurrentHashMap>()
// even though we have trustedMap, we need to keep the trie for wildcard matching
- private val trustedTrie: RadixTree = Backend.newRadixTree()
+ private val trustedTrie: RadixTree by lazy { Backend.newRadixTree() }
// regex to check if url is valid wildcard domain
// valid wildcard domain: *.eu, *.com, *.example.com, *.example.co.in, *.do-main.com
@@ -487,8 +487,12 @@ object DomainRulesManager : KoinComponent {
trie.del(key)
}
+ // Room's DAO returns a new LiveData instance on every call; cache it so
+ // observers and value-reads share the same instance.
+ private val cachedDomainCountLiveData: LiveData by lazy { db.getUniversalCustomDomainCount() }
+
fun getUniversalCustomDomainCount(): LiveData {
- return db.getUniversalCustomDomainCount()
+ return cachedDomainCountLiveData
}
suspend fun getRulesCountByCC(cc: String): Int {
diff --git a/app/src/main/java/com/celzero/bravedns/service/EncryptedFileManager.kt b/app/src/main/java/com/celzero/bravedns/service/EncryptedFileManager.kt
index 0fa194cd7b..f6303f3474 100644
--- a/app/src/main/java/com/celzero/bravedns/service/EncryptedFileManager.kt
+++ b/app/src/main/java/com/celzero/bravedns/service/EncryptedFileManager.kt
@@ -196,7 +196,7 @@ object EncryptedFileManager : KoinComponent {
* @throws EncryptionException for any encryption/decryption failures
*/
@Throws(EncryptionException::class)
- fun read(ctx: Context, file: File): String {
+ suspend fun read(ctx: Context, file: File): String {
val bytes = readByteArray(ctx, file)
return bytes.toString(StandardCharsets.UTF_8)
}
@@ -216,7 +216,7 @@ object EncryptedFileManager : KoinComponent {
* @throws EncryptionException.IOError for file I/O failures
*/
@Throws(EncryptionException::class)
- fun readByteArray(ctx: Context, file: File): ByteArray {
+ suspend fun readByteArray(ctx: Context, file: File): ByteArray {
try {
val masterKey =
MasterKey.Builder(ctx.applicationContext)
@@ -250,7 +250,7 @@ object EncryptedFileManager : KoinComponent {
* @throws EncryptionException for any encryption failures
*/
@Throws(EncryptionException::class)
- fun writeTcpConfig(ctx: Context, cfg: String, fileName: String) {
+ suspend fun writeTcpConfig(ctx: Context, cfg: String, fileName: String) {
val dir =
File(
ctx.filesDir.canonicalPath +
@@ -269,10 +269,12 @@ object EncryptedFileManager : KoinComponent {
/**
* Writes String data to encrypted file.
*
+ * suspend: keystore + disk I/O must never run on the caller's (possibly main) thread.
+ *
* @throws EncryptionException for any encryption failures
*/
@Throws(EncryptionException::class)
- fun write(ctx: Context, data: String, file: File): Boolean {
+ suspend fun write(ctx: Context, data: String, file: File): Boolean {
val d = data.toByteArray(StandardCharsets.UTF_8)
return write(ctx, d, file)
}
@@ -293,7 +295,7 @@ object EncryptedFileManager : KoinComponent {
* @throws EncryptionException.IOError for file I/O failures
*/
@Throws(EncryptionException::class)
- fun write(ctx: Context, data: ByteArray, file: File): Boolean {
+ suspend fun write(ctx: Context, data: ByteArray, file: File): Boolean {
Logger.d(LOG_TAG, "write into ${file.absolutePath}")
return try {
// Delete any existing file first; EncryptedFile refuses to overwrite it.
diff --git a/app/src/main/java/com/celzero/bravedns/service/IpRulesManager.kt b/app/src/main/java/com/celzero/bravedns/service/IpRulesManager.kt
index 886b237ff6..af13d01631 100644
--- a/app/src/main/java/com/celzero/bravedns/service/IpRulesManager.kt
+++ b/app/src/main/java/com/celzero/bravedns/service/IpRulesManager.kt
@@ -42,7 +42,7 @@ object IpRulesManager : KoinComponent {
// separate tries are used internally for ip4 and ip6, if the implementation changes in the
// future, ensure both cases continue to be handled correctly.
- private val iptree = Backend.newIpTree()
+ private val iptree by lazy { Backend.newIpTree() }
// key-value object for ip look-up
data class CacheKey(val ipNetPort: String, val uid: Int)
@@ -103,7 +103,11 @@ object IpRulesManager : KoinComponent {
}
suspend fun load(): Long {
- iptree.clear()
+ try {
+ iptree.clear()
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_FIREWALL, "err iptree.clear()", e)
+ }
db.getIpRules().forEach {
// adding as part of defensive programming, even adding these rules to cache will
// not cause any issues, but to avoid unnecessary entries in the trie, skipping these
@@ -131,8 +135,14 @@ object IpRulesManager : KoinComponent {
}
}
}
- Logger.i(LOG_TAG_FIREWALL, "ip rules loaded, count: ${iptree.len()}")
- return iptree.len()
+ val count = try {
+ iptree.len()
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_FIREWALL, "err iptree.len()", e)
+ -1L
+ }
+ Logger.i(LOG_TAG_FIREWALL, "ip rules loaded, count: $count")
+ return count.coerceAtLeast(0)
}
fun getAllUniqueCCs(): Set {
@@ -144,8 +154,12 @@ object IpRulesManager : KoinComponent {
return db.getRulesCountByCC(cc)
}
+ // Room's DAO returns a new LiveData instance on every call; cache it so
+ // observers and value-reads share the same instance.
+ private val cachedIpsCountLiveData: LiveData by lazy { db.getCustomIpsLiveData() }
+
fun getCustomIpsLiveData(): LiveData {
- return db.getCustomIpsLiveData()
+ return cachedIpsCountLiveData
}
private fun normalize(ipaddr: IPAddress?): String? {
@@ -153,8 +167,25 @@ object IpRulesManager : KoinComponent {
return treeKey(ipaddr.toNormalizedString())
}
+ /**
+ * Never throws: returns the CIDR key for the trie, or null when the input
+ * cannot be enforced by the CIDR-only ip trie. treeKey is invoked from the
+ * per-connection firewall path (hasRule / getMostSpecificRuleMatch) as well
+ * as from rule insertion; the IPAddress library can throw
+ * IncompatibleAddressException on malformed or hostile input, which must
+ * never propagate into the tunnel's decision loop.
+ */
private fun treeKey(ipstr: String?): String? {
if (ipstr == null) return null
+ return try {
+ treeKey0(ipstr)
+ } catch (e: Exception) { // IncompatibleAddressException and friends
+ Logger.w(LOG_TAG_FIREWALL, "err treeKey('$ipstr'); rule stored but not enforced, ${e.message}", e)
+ null
+ }
+ }
+
+ private fun treeKey0(ipstr: String): String? {
// "192/8" -> 0.0.0.192/32
// "192.0.0.0" -> 192.0.0.0/32
// "*.*" -> 0.0.0.0/0
@@ -187,7 +218,27 @@ object IpRulesManager : KoinComponent {
}
singleBlock?.toCanonicalString()
} else {
- ipAddr.toNormalizedString()
+ // The IPAddress library accepts sequential ranges such as
+ // "0.0.6.178-228"; toNormalizedString() would return the range
+ // verbatim, which the Go ip trie rejects (it only accepts CIDR
+ // notation) and panics across JNI (go.Universe$proxyerror). Convert
+ // to a single CIDR block when possible (e.g. "1.2.252-255" =>
+ // 1.2.252.0/22); otherwise return null so the rule is stored but not
+ // enforced (same as non-CIDR-able wildcards above).
+ if (!ipAddr.isMultiple) {
+ ipAddr.toNormalizedString()
+ } else {
+ val singleBlock = try {
+ ipAddr.assignPrefixForSingleBlock()
+ } catch (e: Exception) { // IncompatibleAddressException for non-prefix-block ranges
+ Logger.w(LOG_TAG_FIREWALL, "err converting range '$ipstr' to CIDR block", e)
+ null
+ }
+ if (singleBlock == null) {
+ Logger.w(LOG_TAG_FIREWALL, "ip range '$ipstr' has no single CIDR block; rule stored but not enforced")
+ }
+ singleBlock?.toCanonicalString()
+ }
}
}
@@ -212,7 +263,13 @@ object IpRulesManager : KoinComponent {
db.deleteRule(uid, ipstr, port)
val k = treeKey(ipstr)
- if (!k.isNullOrEmpty()) iptree.escLike(k, treeValLike(uid, port))
+ if (!k.isNullOrEmpty()) {
+ try {
+ iptree.escLike(k, treeValLike(uid, port))
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_FIREWALL, "err iptree.escLike($k) for uid: $uid", e)
+ }
+ }
resultsCache.invalidateAll()
}
@@ -226,9 +283,13 @@ object IpRulesManager : KoinComponent {
Logger.i(LOG_TAG_FIREWALL, "ip rule, update: $ipaddr for uid: ${ci.uid}; status: ${ci.status}")
if (!k.isNullOrEmpty()) {
- // escape old entries and add updated rule using ci.port (not android attr)
- iptree.escLike(k, treeValLike(ci.uid, ci.port))
- iptree.add(k, treeVal(ci.uid, ci.port, ci.status, ci.proxyId, ci.proxyCC))
+ try {
+ // escape old entries and add updated rule using ci.port (not android attr)
+ iptree.escLike(k, treeValLike(ci.uid, ci.port))
+ iptree.add(k, treeVal(ci.uid, ci.port, ci.status, ci.proxyId, ci.proxyCC))
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_FIREWALL, "err iptree.add($k) for uid: ${ci.uid}", e)
+ }
}
resultsCache.invalidateAll()
}
@@ -368,7 +429,12 @@ object IpRulesManager : KoinComponent {
val vlike = treeValLike(uid, port)
// rules at the end of the list have higher precedence as they're more specific
// (think: 0.0.0.0/0 vs 1.1.1.1/32)
- val x = iptree.getLike(k, vlike)
+ val x = try {
+ iptree.getLike(k, vlike)
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_FIREWALL, "err iptree.getLike($k, $vlike) for uid: $uid", e)
+ return IpRuleStatus.NONE
+ }
logv("getMostSpecificRuleMatch: $uid, $k, $vlike => $x")
val treeValues = x?.split(Backend.Vsep) ?: return IpRuleStatus.NONE
treeValues.reversed().forEach {
@@ -418,7 +484,12 @@ object IpRulesManager : KoinComponent {
val vlike = treeValLike(uid, port)
// rules at the end of the list have higher precedence as they're more specific
// (think: 0.0.0.0/0 vs 1.1.1.1/32)
- val x = iptree.getLike(k, vlike)
+ val x = try {
+ iptree.getLike(k, vlike)
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_FIREWALL, "err iptree.getLike($k, $vlike) for uid: $uid", e)
+ return Pair("", "")
+ }
if (DEBUG) logv("getMostSpecificRuleMatch: $uid, $k, $vlike => $x")
val treeVals = x?.split(Backend.Vsep) ?: return Pair("","")
@@ -443,7 +514,12 @@ object IpRulesManager : KoinComponent {
val vlike = treeValLike(uid, port)
// rules at the end of the list have higher precedence as they're more specific
// (think: 0.0.0.0/0 vs 1.1.1.1/32)
- val x = iptree.valuesLike(k, vlike)
+ val x = try {
+ iptree.valuesLike(k, vlike)
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_FIREWALL, "err iptree.valuesLike($k, $vlike) for uid: $uid", e)
+ return IpRuleStatus.NONE
+ }
// ex: uid: 10169, k: 142.250.67.78, vlike: 10169:443 => x: 10169:443:0
// (10169:443:0) => (uid : port : rule[0->none, 1-> block, 2 -> trust, 3 -> bypass])
logv("getMostSpecificRouteMatch: $uid, $k, $vlike => $x")
@@ -469,7 +545,12 @@ object IpRulesManager : KoinComponent {
val vlike = treeValLike(uid, port)
// rules at the end of the list have higher precedence as they're more specific
// (think: 0.0.0.0/0 vs 1.1.1.1/32)
- val x = iptree.valuesLike(k, vlike)
+ val x = try {
+ iptree.valuesLike(k, vlike)
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_FIREWALL, "err iptree.valuesLike($k, $vlike) for uid: $uid", e)
+ return Pair("", "")
+ }
// ex: uid: 10169, k: 142.250.67.78, vlike: 10169:443 => x: 10169:443:0
// (10169:443:0) => (uid : port : rule[0->none, 1-> block, 2 -> trust, 3 -> bypass])
logv("getMostSpecificRouteMatch: $uid, $k, $vlike => $x")
@@ -496,7 +577,11 @@ object IpRulesManager : KoinComponent {
val port = pair.second
val k = normalize(ipaddr)
if (!k.isNullOrEmpty()) {
- iptree.esc(k, treeVal(it.uid, port, it.status, it.proxyId, it.proxyCC))
+ try {
+ iptree.esc(k, treeVal(it.uid, port, it.status, it.proxyId, it.proxyCC))
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_FIREWALL, "err iptree.esc($k) for uid: ${it.uid}", e)
+ }
}
}
db.deleteRulesByUid(uid)
@@ -511,7 +596,11 @@ object IpRulesManager : KoinComponent {
val port = pair.second
val k = normalize(ipaddr)
if (!k.isNullOrEmpty()) {
- iptree.esc(k, treeVal(it.uid, port, it.status, it.proxyId, it.proxyCC))
+ try {
+ iptree.esc(k, treeVal(it.uid, port, it.status, it.proxyId, it.proxyCC))
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_FIREWALL, "err iptree.esc($k) for uid: ${it.uid}", e)
+ }
}
}
db.deleteRules(list)
@@ -520,7 +609,11 @@ object IpRulesManager : KoinComponent {
suspend fun deleteAllAppsRules() {
db.deleteAllAppsRules()
- iptree.clear()
+ try {
+ iptree.clear()
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_FIREWALL, "err iptree.clear()", e)
+ }
resultsCache.invalidateAll()
}
@@ -666,9 +759,13 @@ object IpRulesManager : KoinComponent {
db.insert(c)
val k = treeKey(normalizedIp)
if (!k.isNullOrEmpty()) {
- iptree.escLike(k, treeValLike(uid, port ?: 0))
- iptree.add(k, treeVal(uid, port ?: 0, status.id, proxyId, proxyCC))
- Logger.d(LOG_TAG_FIREWALL, "iptree.add($k, ${treeVal(uid, port ?: 0, status.id, proxyId, proxyCC)})")
+ try {
+ iptree.escLike(k, treeValLike(uid, port ?: 0))
+ iptree.add(k, treeVal(uid, port ?: 0, status.id, proxyId, proxyCC))
+ Logger.d(LOG_TAG_FIREWALL, "iptree.add($k, ${treeVal(uid, port ?: 0, status.id, proxyId, proxyCC)})")
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_FIREWALL, "err iptree.add($k) for uid: $uid", e)
+ }
}
resultsCache.invalidateAll()
return c
@@ -730,12 +827,20 @@ object IpRulesManager : KoinComponent {
db.insert(newRule)
val pk = treeKey(prevIpAddrStr)
if (!pk.isNullOrEmpty()) {
- iptree.escLike(pk, treeValLike(prevRule.uid, prevRule.port))
+ try {
+ iptree.escLike(pk, treeValLike(prevRule.uid, prevRule.port))
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_FIREWALL, "err iptree.escLike($pk) for uid: ${prevRule.uid}", e)
+ }
}
val nk = treeKey(newIpAddrStr)
if (!nk.isNullOrEmpty()) {
- iptree.escLike(nk, treeValLike(newRule.uid, port ?: 0))
- iptree.add(nk, treeVal(newRule.uid, port ?: 0, newStatus.id, proxyId, proxyCC))
+ try {
+ iptree.escLike(nk, treeValLike(newRule.uid, port ?: 0))
+ iptree.add(nk, treeVal(newRule.uid, port ?: 0, newStatus.id, proxyId, proxyCC))
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_FIREWALL, "err iptree.add($nk) for uid: ${newRule.uid}", e)
+ }
}
resultsCache.invalidateAll()
}
@@ -806,6 +911,26 @@ object IpRulesManager : KoinComponent {
return Triple(host, port, err)
}
+ /**
+ * Returns true if the parsed address can be enforced by the CIDR-only ip
+ * trie. Single addresses, CIDR notation, and wildcards that align to a
+ * single CIDR block are always enforceable. Sequential ranges such as
+ * "0.0.6.178-228" are enforceable only when their span aligns to a single
+ * CIDR block (e.g. "1.2.252-255.*" => 1.2.252.0/22); anything else would
+ * be rejected by the Go trie (see the crash in
+ * Backend$proxyIpTree.add) and should be refused by the UI.
+ */
+ fun isCidrEnforceable(ipaddr: IPAddress?): Boolean {
+ if (ipaddr == null) return false
+ return try {
+ if (!ipaddr.isMultiple) return true
+ ipaddr.assignPrefixForSingleBlock() != null
+ } catch (e: Exception) { // IncompatibleAddressException for non-prefix-block inputs
+ Logger.w(LOG_TAG_FIREWALL, "err isCidrEnforceable, ${e.message}", e)
+ false
+ }
+ }
+
fun getIpNetPort(inp: String): Pair {
val h = splitHostPort(inp)
var ipNet: IPAddress? = null
@@ -854,7 +979,13 @@ object IpRulesManager : KoinComponent {
suspend fun stats(): String {
val sb = StringBuilder()
- sb.append(" iptree len: ${iptree.len()}\n")
+ val treeLen = try {
+ iptree.len()
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_FIREWALL, "err iptree.len()", e)
+ -1L
+ }
+ sb.append(" iptree len: $treeLen\n")
sb.append(" db len: ${db.getRulesCount()}\n")
return sb.toString()
@@ -868,7 +999,12 @@ object IpRulesManager : KoinComponent {
val normalized = normalize(ipaddr).orEmpty()
if (normalized.isEmpty()) return@any false
- val res = iptree.valuesLike(normalized, treeValLike(uid)) ?: return@any false
+ val res = try {
+ iptree.valuesLike(normalized, treeValLike(uid))
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_FIREWALL, "err iptree.valuesLike($normalized) for uid: $uid", e)
+ return@any false
+ } ?: return@any false
val reversed = res.split(Backend.Vsep).reversed()
if (reversed.isEmpty()) return@any false
diff --git a/app/src/main/java/com/celzero/bravedns/service/LogActivityAggregator.kt b/app/src/main/java/com/celzero/bravedns/service/LogActivityAggregator.kt
new file mode 100644
index 0000000000..32b4ba1cba
--- /dev/null
+++ b/app/src/main/java/com/celzero/bravedns/service/LogActivityAggregator.kt
@@ -0,0 +1,427 @@
+/*
+ * Copyright 2026 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.service
+
+import com.celzero.bravedns.database.ActivityBucketRow
+import com.celzero.bravedns.database.ConnectionTrackerRepository
+import com.celzero.bravedns.database.DnsLogRepository
+import com.celzero.bravedns.database.RethinkLogRepository
+import com.celzero.bravedns.util.Logger
+import com.celzero.bravedns.util.Logger.LOG_TAG_VPN
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import java.time.Clock
+
+data class LogActivityEvent(
+ val timestampMs: Long,
+ val source: LogActivitySource,
+ val blocked: Boolean,
+ // unique per-event identity used to guard against duplicate callbacks;
+ // null when the underlying record has no stable id (dns logs)
+ val key: String? = null
+)
+
+enum class LogActivitySource {
+ DNS,
+ NETWORK
+}
+
+/**
+ * Immutable counts for one cell of the activity wall: a single 10-minute
+ * bucket within the last 24 hours.
+ */
+data class LogActivityInterval(
+ val startTimestamp: Long,
+ val blocked: Long,
+ val allowed: Long,
+ val dnsBlocked: Long,
+ val dnsAllowed: Long,
+ val networkBlocked: Long,
+ val networkAllowed: Long
+)
+
+/**
+ * Immutable snapshot of the activity wall: [intervals] holds
+ * [LogActivityAggregator.TOTAL_SLOTS] (= [LogActivityAggregator.HOURS_IN_WINDOW]
+ * x [LogActivityAggregator.BUCKETS_PER_HOUR]) entries in chronological order
+ * (oldest bucket first, newest bucket last), ending at [windowEndMs]. The
+ * grid maps a flat index i to column = i / [LogActivityAggregator.BUCKETS_PER_HOUR]
+ * (hour, oldest left) and row = i % [LogActivityAggregator.BUCKETS_PER_HOUR]
+ * (10-minute bucket within that hour, :00 at top).
+ */
+data class LogActivityState(
+ val windowEndMs: Long,
+ val intervals: List
+) {
+ companion object {
+ fun empty(windowEndMs: Long): LogActivityState {
+ val emptyInterval = LogActivityInterval(0L, 0L, 0L, 0L, 0L, 0L, 0L)
+ return LogActivityState(
+ windowEndMs,
+ List(LogActivityAggregator.TOTAL_SLOTS) { emptyInterval }
+ )
+ }
+ }
+}
+
+/**
+ * In-memory aggregation of blocked/allowed DNS and network activity into the
+ * home-screen activity wall: the last [HOURS_IN_WINDOW] hours (24 columns,
+ * oldest left, latest right) split into [BUCKETS_PER_HOUR] ten-minute buckets
+ * per hour (6 rows within each column, :00 at top, :50 at bottom). The newest
+ * cell (current 10-minute bucket) is the bottom-right cell.
+ *
+ * This is a cache over the log databases, not a second source of truth:
+ * - The database is authoritative; [restoreFromDatabase] rebuilds the whole
+ * wall via grouped SQL queries (one per table over the full 24-hour range;
+ * never by loading individual rows). It runs once when BraveVPNService is
+ * created, not on every UI resume.
+ * - [recordOnArrival] is invoked by the log-producing callers
+ * (TunDnsManager.handleOnResponse / TunFlowManager write sites) immediately
+ * when a log arrives, decoupled from the batched database write. A failed or
+ * dropped db write can leave the wall briefly optimistic until the next
+ * reconciliation. The trackers themselves stay persistence-only.
+ * - Postflow updates never change the isBlocked classification of an existing
+ * row (verified against ConnectionTrackerDAO/RethinkLogDao updateSummary);
+ * should that ever change, [reclassify] applies previous->new as a delta.
+ * - The window slides on every 10-minute boundary: as time advances, the
+ * oldest bucket is dropped and history shifts left by one, so the wall
+ * always covers the trailing 24 hours.
+ *
+ * Threading: producers run concurrently on arbitrary Go-bridge threads, so all
+ * mutations are serialized behind [mutex] and published as immutable snapshots
+ * on a StateFlow. Consumers must only read [activity].
+ */
+class LogActivityAggregator(
+ private val dnsLogRepository: DnsLogRepository,
+ private val connectionTrackerRepository: ConnectionTrackerRepository,
+ private val rethinkLogRepository: RethinkLogRepository,
+ private val clock: Clock = Clock.systemDefaultZone(),
+ arrivalDispatcher: CoroutineDispatcher = Dispatchers.Default
+) {
+
+ companion object {
+ // wall granularity: one cell == one ten-minute interval
+ const val BUCKET_MS = 10L * 60L * 1000L
+
+ // rows within one hour-column: 6 x 10 min == 1 hour
+ const val BUCKETS_PER_HOUR = 6
+
+ // wall width: how many hours the wall spans (columns), oldest first
+ const val HOURS_IN_WINDOW = 24
+
+ const val TOTAL_SLOTS = BUCKETS_PER_HOUR * HOURS_IN_WINDOW
+
+ // convenience for callers that reason in whole hours
+ const val HOUR_MS = BUCKET_MS * BUCKETS_PER_HOUR
+
+ private const val TAG = "LogActivityAggregator;"
+ // dedupe window for network events (connId based); bounded to keep
+ // memory flat
+ private const val DEDUPE_CAPACITY = 4096
+
+ /**
+ * Epoch-millis of the 10-minute bucket containing [timestampMs]
+ * (buckets are epoch-aligned, so no timezone involvement).
+ */
+ fun bucketFloor(timestampMs: Long): Long {
+ if (timestampMs < 0) return 0
+ return timestampMs - (timestampMs % BUCKET_MS)
+ }
+
+ /**
+ * Wall slot for an event given the newest bucket start
+ * [currentBucketStart]: index TOTAL_SLOTS-1 is the current bucket,
+ * index 0 the oldest (a full 24 hours back). Returns -1 when the
+ * event lies outside the wall (older than the window or in the
+ * future).
+ */
+ fun slotIndex(timestampMs: Long, currentBucketStart: Long): Long {
+ val bucket = bucketFloor(timestampMs) / BUCKET_MS
+ val current = currentBucketStart / BUCKET_MS
+ val ageBuckets = current - bucket
+ if (ageBuckets < 0 || ageBuckets >= TOTAL_SLOTS) return -1L
+ return TOTAL_SLOTS - 1 - ageBuckets
+ }
+
+ /**
+ * Epoch-millis at which slot [index] of the wall begins; index
+ * TOTAL_SLOTS-1 starts at [currentBucketStart].
+ */
+ fun slotStart(currentBucketStart: Long, index: Int): Long {
+ return currentBucketStart - (TOTAL_SLOTS - 1 - index) * BUCKET_MS
+ }
+ }
+
+ // newest 10-minute bucket of the wall (epoch-aligned floor of "now");
+ // the wall covers [currentBucketStart - (TOTAL_SLOTS-1)*BUCKET_MS,
+ // currentBucketStart + BUCKET_MS)
+ private var currentBucketStart: Long = bucketFloor(clock.millis())
+
+ private val mutex = Mutex()
+
+ // fire-and-forget entry point for arrival-time recording from callers that
+ // have no ambient coroutine scope (go-bridge callbacks, cache listeners);
+ // never canceled: the aggregator is a process-wide singleton
+ private val arrivalScope = CoroutineScope(SupervisorJob() + arrivalDispatcher)
+
+ @Volatile
+ private var restoredForBucketStart: Long = Long.MIN_VALUE
+
+ // Set synchronously by [recordOnArrival] (before the event is dispatched)
+ // and under [mutex] by [record]/[reclassify] when they mutate the wall.
+ // While set, [restoreFromDatabase] must not rebuild from the database:
+ // events applied since the last snapshot may not be persisted yet (db
+ // writes are batched), so a rebuild would silently wipe them — and events
+ // queued behind the mutex would be double counted once the snapshot
+ // already contains them. See [restoreFromDatabase].
+ @Volatile
+ private var hasRecordedSinceRestore: Boolean = false
+
+ // flat wall counters, chronological: index 0 = oldest bucket,
+ // TOTAL_SLOTS-1 = current bucket
+ private val dnsBlocked = LongArray(TOTAL_SLOTS)
+ private val dnsAllowed = LongArray(TOTAL_SLOTS)
+ private val nwBlocked = LongArray(TOTAL_SLOTS)
+ private val nwAllowed = LongArray(TOTAL_SLOTS)
+
+ // connId-based dedupe; insertion-order preserving with bounded capacity
+ private val seenNetworkKeys = object : LinkedHashMap(
+ DEDUPE_CAPACITY, 0.75f, false
+ ) {
+ override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean {
+ return size > DEDUPE_CAPACITY
+ }
+ }
+
+ private val _activity = MutableStateFlow(
+ LogActivityState.empty(bucketFloor(clock.millis()) + BUCKET_MS)
+ )
+ val activity: StateFlow = _activity.asStateFlow()
+
+ init {
+ publishSnapshot()
+ }
+
+ /**
+ * Non-suspend, non-blocking arrival hook for log-producing callers.
+ * Applies the event on [arrivalScope] and returns immediately; counts are
+ * commutative so cross-event ordering is irrelevant. See [record] for
+ * slide/dedupe semantics.
+ */
+ fun recordOnArrival(event: LogActivityEvent) {
+ // flag synchronously: a queued-but-not-yet-applied arrival must make
+ // the next restore treat the wall as live (see restoreFromDatabase)
+ hasRecordedSinceRestore = true
+ arrivalScope.launch { record(listOf(event)) }
+ }
+
+ suspend fun record(events: List) {
+ mutex.withLock {
+ var mutated = false
+ for (event in events) {
+ if (!shouldRecord(event)) continue
+ slideWindowIfNeeded(event.timestampMs)
+ applyEvent(event, +1)
+ mutated = true
+ }
+ if (mutated) {
+ hasRecordedSinceRestore = true
+ publishSnapshot()
+ }
+ }
+ }
+
+ /**
+ * Applies previous -> new as a delta, moving counts between slots when the
+ * classification (or timestamp) changed during an update of an existing
+ * record. No-op when both sides are null.
+ */
+ suspend fun reclassify(previous: LogActivityEvent?, new: LogActivityEvent?) {
+ if (previous == null && new == null) return
+ mutex.withLock {
+ if (previous != null) applyEvent(previous, -1)
+ if (new != null) {
+ slideWindowIfNeeded(new.timestampMs)
+ applyEvent(new, +1)
+ }
+ hasRecordedSinceRestore = true
+ publishSnapshot()
+ }
+ }
+
+ /**
+ * Rebuilds the entire wall from the databases using grouped queries (one
+ * per table over the full trailing-24h range; never by loading individual
+ * rows). Called from BraveVPNService.onCreate; also re-anchors the window
+ * for long-lived processes. Idempotent.
+ *
+ * Restoration is atomic with respect to arrival recording: when events
+ * have been recorded (or are queued) since the last snapshot, the wall is
+ * considered live and the rebuild is skipped — a database snapshot cannot
+ * tell whether an already-applied event has been flushed to disk yet
+ * (batched writes), so rebuilding would either drop it, or double count a
+ * queued arrival the snapshot already contains. Instead the window is
+ * re-anchored by sliding; the next restore that runs while no arrivals
+ * happened performs the full rebuild.
+ */
+ suspend fun restoreFromDatabase() {
+ try {
+ mutex.withLock {
+ if (restoredForBucketStart == currentBucketStart && loaded) return
+ val previousBucketStart = currentBucketStart
+ currentBucketStart = bucketFloor(clock.millis())
+
+ if (hasRecordedSinceRestore) {
+ slide(((currentBucketStart - previousBucketStart) / BUCKET_MS).coerceAtLeast(0).toInt())
+ loaded = true
+ restoredForBucketStart = currentBucketStart
+ publishSnapshot()
+ return
+ }
+
+ val rangeStart = currentBucketStart - (TOTAL_SLOTS - 1) * BUCKET_MS
+ val rangeEnd = currentBucketStart + BUCKET_MS
+
+ dnsBlocked.fill(0); dnsAllowed.fill(0)
+ nwBlocked.fill(0); nwAllowed.fill(0)
+ mergeInto(
+ dnsLogRepository.getActivityBuckets(rangeStart, rangeEnd, BUCKET_MS),
+ dnsBlocked, dnsAllowed
+ )
+ mergeInto(
+ connectionTrackerRepository.getActivityBuckets(rangeStart, rangeEnd, BUCKET_MS),
+ nwBlocked, nwAllowed
+ )
+ mergeInto(
+ rethinkLogRepository.getActivityBuckets(rangeStart, rangeEnd, BUCKET_MS),
+ nwBlocked, nwAllowed
+ )
+
+ loaded = true
+ restoredForBucketStart = currentBucketStart
+ hasRecordedSinceRestore = false
+ publishSnapshot()
+ }
+ } catch (e: Exception) {
+ Logger.e(LOG_TAG_VPN, "$TAG restore failed: ${e.message}", e)
+ }
+ }
+
+ /**
+ * True while the in-memory wall has not (yet) been reconciled with the
+ * databases for the current 10-minute bucket.
+ */
+ fun isStale(): Boolean {
+ return restoredForBucketStart != bucketFloor(clock.millis())
+ }
+
+ private var loaded: Boolean = false
+
+ private fun shouldRecord(event: LogActivityEvent): Boolean {
+ val key = event.key ?: return true
+ // put returns the previous value: null only when the key is new
+ return seenNetworkKeys.put(key, true) == null
+ }
+
+ /**
+ * Slides the wall forward when time has moved past [currentBucketStart]:
+ * shifts all counters left by the elapsed bucket count so the wall always
+ * covers the trailing 24 hours instead of growing stale. Never moves the
+ * window backward (late events simply land in their existing slot or get
+ * dropped when older than the window).
+ */
+ private fun slideWindowIfNeeded(timestampMs: Long) {
+ val bucket = bucketFloor(timestampMs)
+ if (bucket <= currentBucketStart) return
+ val delta = ((bucket - currentBucketStart) / BUCKET_MS).toInt()
+ Logger.v(
+ LOG_TAG_VPN,
+ "$TAG wall slid forward $currentBucketStart -> $bucket ($delta buckets)"
+ )
+ slide(delta)
+ currentBucketStart = bucket
+ }
+
+ private fun slide(deltaBuckets: Int) {
+ if (deltaBuckets <= 0) return
+ val shift = deltaBuckets.coerceAtMost(TOTAL_SLOTS)
+ slideLeft(dnsBlocked, shift)
+ slideLeft(dnsAllowed, shift)
+ slideLeft(nwBlocked, shift)
+ slideLeft(nwAllowed, shift)
+ }
+
+ private fun slideLeft(arr: LongArray, shift: Int) {
+ if (shift >= arr.size) {
+ arr.fill(0)
+ return
+ }
+ System.arraycopy(arr, shift, arr, 0, arr.size - shift)
+ arr.fill(0, arr.size - shift, arr.size)
+ }
+
+ private fun applyEvent(event: LogActivityEvent, sign: Int) {
+ val idx = slotIndex(event.timestampMs, currentBucketStart)
+ if (idx < 0) return // outside the wall window
+ val i = idx.toInt() // arrays are bounded to TOTAL_SLOTS; idx fits Int
+ val magnitude = if (sign > 0) 1L else -1L
+ when (event.source) {
+ LogActivitySource.DNS -> {
+ if (event.blocked) dnsBlocked[i] += magnitude
+ else dnsAllowed[i] += magnitude
+ }
+ LogActivitySource.NETWORK -> {
+ if (event.blocked) nwBlocked[i] += magnitude
+ else nwAllowed[i] += magnitude
+ }
+ }
+ }
+
+ private fun mergeInto(rows: List, blocked: LongArray, allowed: LongArray) {
+ for (row in rows) {
+ val idx = row.bucketIndex.coerceIn(0L, TOTAL_SLOTS.toLong() - 1L).toInt()
+ if (row.blocked != 0) {
+ blocked[idx] += row.total
+ } else {
+ allowed[idx] += row.total
+ }
+ }
+ }
+
+ private fun publishSnapshot() {
+ val intervals = List(TOTAL_SLOTS) { i ->
+ LogActivityInterval(
+ startTimestamp = slotStart(currentBucketStart, i),
+ blocked = dnsBlocked[i] + nwBlocked[i],
+ allowed = dnsAllowed[i] + nwAllowed[i],
+ dnsBlocked = dnsBlocked[i],
+ dnsAllowed = dnsAllowed[i],
+ networkBlocked = nwBlocked[i],
+ networkAllowed = nwAllowed[i]
+ )
+ }
+ _activity.value = LogActivityState(currentBucketStart + BUCKET_MS, intervals)
+ }
+}
diff --git a/app/src/main/java/com/celzero/bravedns/service/LogActivityWindow.kt b/app/src/main/java/com/celzero/bravedns/service/LogActivityWindow.kt
new file mode 100644
index 0000000000..6bb7d14a0d
--- /dev/null
+++ b/app/src/main/java/com/celzero/bravedns/service/LogActivityWindow.kt
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2026 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.service
+
+/**
+ * A half-open [startMs, endMs) time window snapped to ten-minute interval
+ * boundaries, used by the activity detail sheet. The default window is the
+ * last 10 minutes; users can look back up to 24 hours at 10-minute
+ * granularity ([MAX_LOOKBACK_MS]).
+ */
+data class LogActivityWindow(
+ val startMs: Long,
+ val endMs: Long
+) {
+
+ companion object {
+ const val TEN_MINUTES_MS = 10L * 60L * 1000L
+
+ // selectable history cap: 24 hours of 10-minute intervals
+ const val MAX_LOOKBACK_MS = 24L * 60L * 60L * 1000L
+
+ private val PRESETS_MS = longArrayOf(
+ TEN_MINUTES_MS, // 10 minutes (default)
+ 60L * 60L * 1000L, // 1 hour
+ MAX_LOOKBACK_MS // 24 hours
+ )
+
+ fun presetDurations(): LongArray = PRESETS_MS.copyOf()
+
+ fun defaultPresetIndex(): Int = 0
+
+ /**
+ * Window for preset [presetIndex] ending at the ten-minute boundary at or
+ * before [nowMs]. Clamped to [MAX_LOOKBACK_MS].
+ */
+ fun fromPreset(presetIndex: Int, nowMs: Long): LogActivityWindow {
+ val durations = PRESETS_MS
+ val idx = presetIndex.coerceIn(0, durations.size - 1)
+ return last(durations[idx], nowMs)
+ }
+
+ /**
+ * Window covering the [durationMs] leading up to the ten-minute boundary
+ * at or before [nowMs].
+ */
+ fun last(durationMs: Long, nowMs: Long): LogActivityWindow {
+ val duration = durationMs.coerceIn(TEN_MINUTES_MS, MAX_LOOKBACK_MS)
+ val end = snapToInterval(nowMs)
+ return LogActivityWindow(end - duration, end)
+ }
+
+ /**
+ * Single ten-minute interval whose start is [intervalStartMs].
+ */
+ fun singleInterval(intervalStartMs: Long): LogActivityWindow {
+ return LogActivityWindow(intervalStartMs, intervalStartMs + TEN_MINUTES_MS)
+ }
+
+ /**
+ * Floors [timestampMs] onto its ten-minute interval start.
+ */
+ fun snapToInterval(timestampMs: Long): Long {
+ if (timestampMs < 0) return 0
+ return timestampMs - (timestampMs % TEN_MINUTES_MS)
+ }
+ }
+}
diff --git a/app/src/main/java/com/celzero/bravedns/service/PersistentState.kt b/app/src/main/java/com/celzero/bravedns/service/PersistentState.kt
index 4e31a4587a..bd21ef0d9d 100644
--- a/app/src/main/java/com/celzero/bravedns/service/PersistentState.kt
+++ b/app/src/main/java/com/celzero/bravedns/service/PersistentState.kt
@@ -24,6 +24,7 @@ import com.celzero.bravedns.database.DnsCryptRelayEndpoint
import com.celzero.bravedns.rpnproxy.RpnProxyManager
import com.celzero.bravedns.ui.activity.AntiCensorshipActivity
import com.celzero.bravedns.ui.bottomsheet.BlockFreeDnsModeBottomSheet
+import com.celzero.bravedns.ui.stats.StatsViewMode
import com.celzero.bravedns.util.Constants
import com.celzero.bravedns.util.Constants.Companion.INIT_TIME_MS
import com.celzero.bravedns.util.Constants.Companion.INVALID_PORT
@@ -43,6 +44,11 @@ import hu.autsoft.krate.stringPref
import org.koin.core.component.KoinComponent
class PersistentState(context: Context) : SimpleKrate(context), KoinComponent {
+
+ // constructor param promoted to a property so it is accessible from
+ // methods (SimpleKrate in krate 2.0.0 does not expose the context)
+ private val appContext: Context = context
+
companion object {
const val BRAVE_MODE = "brave_mode"
const val BACKGROUND_MODE = "background_mode"
@@ -106,6 +112,12 @@ class PersistentState(context: Context) : SimpleKrate(context), KoinComponent {
// v2: added Rethink+ premium nav-item step (step 6) + fixed tour button text contrast.
const val GUIDED_TOUR_CURRENT_VERSION = 2
+ // RPN (ServerSelection) onboarding tour version. Bump to re-show the premium
+ // RPN onboarding to existing subscribers after a major dashboard UI change.
+ // v1: initial post-purchase onboarding (quick tiles, relay, bypass, per-app
+ // routing, stats, DNS-filter settings).
+ const val RPN_ONBOARDING_CURRENT_VERSION = 1
+
const val FLOOD_WIREGUARD = "flood_wireguard"
const val SOCKET_BUFFER_SIZE_BYTES = "socket_buffer_size_bytes"
@@ -117,6 +129,21 @@ class PersistentState(context: Context) : SimpleKrate(context), KoinComponent {
// SAF tree URI (content://...) of the directory chosen by the user to store
// memory-profile heap dumps. Empty when the user hasn't picked a location yet.
const val MEMORY_PROFILE_DIR_URI = "memory_profile_dir_uri"
+
+ // Default custom LAN IPs for the VPN tunnel (used with the withDefault{} values
+ // above and by restoreTunnelSettingsDefaults())
+ private const val DEFAULT_LAN_GATEWAY_IPV4 = "10.111.222.1/24"
+ private const val DEFAULT_LAN_GATEWAY_IPV6 = "fd66:f83a:c650::1/120"
+ private const val DEFAULT_LAN_ROUTER_IPV4 = "10.111.222.2/32"
+ private const val DEFAULT_LAN_ROUTER_IPV6 = "fd66:f83a:c650::2/128"
+ private const val DEFAULT_LAN_DNS_IPV4 = "10.111.222.3/32"
+ private const val DEFAULT_LAN_DNS_IPV6 = "fd66:f83a:c650::3/128"
+
+ // Default socket buffer size: 512 KB (512 * 1024)
+ private const val DEFAULT_SOCKET_BUFFER_SIZE_BYTES = 524288
+
+ // Default VPN builder network policy (0 = auto)
+ private const val DEFAULT_VPN_BUILDER_POLICY = 0
}
// when vpn is started by the user, this is set to true; set to false when user stops
@@ -253,6 +280,10 @@ class PersistentState(context: Context) : SimpleKrate(context), KoinComponent {
var customDownloaderLastGeneratedId by
longPref("custom_downloader_last_generated_id").withDefault(0)
+ // last download failure reason
+ var lastDownloadFailureReason by
+ stringPref("last_download_failure_reason").withDefault("")
+
// android download manager's active download ids (comma-separated)
var androidDownloadManagerIds by
stringPref("android_download_manager_ids").withDefault("")
@@ -320,7 +351,7 @@ class PersistentState(context: Context) : SimpleKrate(context), KoinComponent {
var enableDnsCache by booleanPref("dns_cache").withDefault(true)
// private ips, default false (route private ips to tunnel)
- var privateIps by booleanPref("private_ips").withDefault(false)
+ var privateIps by booleanPref("private_ips").withDefault(Utilities.isPlayStoreFlavour())
// biometric last auth time
var biometricAuthTime by longPref("biometric_auth_time").withDefault(INIT_TIME_MS)
@@ -415,7 +446,7 @@ class PersistentState(context: Context) : SimpleKrate(context), KoinComponent {
var appUpdateTimeTs by longPref("app_update_time_ts").withDefault(INIT_TIME_MS)
// 0 - auto, 1 - relaxed, 2 - aggressive, 3 - fixed
- var vpnBuilderPolicy by intPref("tun_network_handling_policy").withDefault(0)
+ var vpnBuilderPolicy by intPref("tun_network_handling_policy").withDefault(DEFAULT_VPN_BUILDER_POLICY)
// Block-free DNS: stored as "TYPE::url" e.g. "DOH::https://dns.google/dns-query"
// Empty string means no block-free DNS configured
@@ -470,11 +501,14 @@ class PersistentState(context: Context) : SimpleKrate(context), KoinComponent {
var floodWireGuard by booleanPref("flood_wireguard").withDefault(false)
- var socketBufferSizeBytes by intPref("socket_buffer_size_bytes").withDefault(524288) // 512 KB (512 * 1024)
+ var socketBufferSizeBytes by intPref("socket_buffer_size_bytes").withDefault(DEFAULT_SOCKET_BUFFER_SIZE_BYTES)
// include file trace in logs
var includeFileTrace by booleanPref(INCLUDE_FILE_TRACE).withDefault(false)
+ // stats screen presentation mode, see enum StatsViewMode (0 - list, 1 - insights)
+ var statsViewMode by intPref("stats_view_mode").withDefault(StatsViewMode.INSIGHTS.id)
+
var orbotConnectionStatus: MutableLiveData = MutableLiveData()
var vpnEnabledLiveData: MutableLiveData = MutableLiveData()
var universalRulesCount: MutableLiveData = MutableLiveData()
@@ -490,6 +524,11 @@ class PersistentState(context: Context) : SimpleKrate(context), KoinComponent {
fun setVpnEnabled(isOn: Boolean) {
vpnEnabledLiveData.postValue(isOn)
_vpnEnabled = isOn
+ // Push the quick-settings tile to re-bind and re-seed its state. Tile
+ // updates are dropped by SystemUI while the tile is not listening, so
+ // without this push the tile would keep showing its last-rendered
+ // state until the user next pulls down the shade.
+ QuickSettingsTileHelper.requestTileUpdate(appContext)
}
fun getVpnEnabled(): Boolean {
@@ -661,12 +700,13 @@ class PersistentState(context: Context) : SimpleKrate(context), KoinComponent {
* @return true if the state was changed (i.e., it was newly enabled), false otherwise.
*/
fun enableStabilityDependentSettings(): Boolean {
- // Skip for fdroid flavor
- if (Utilities.isFdroidFlavour()) {
+ // Stability program is backed by Firebase error reporting, which is
+ // available in the play flavour only; skip for website and fdroid.
+ if (!Utilities.isPlayStoreFlavour()) {
return false
}
- // Enable Firebase error reporting for play and website variants
+ // Enable Firebase error reporting for the play variant
if (!firebaseErrorReportingEnabled) {
firebaseErrorReportingEnabled = true
FirebaseErrorReporting.setEnabled(firebaseErrorReportingEnabled)
@@ -679,14 +719,7 @@ class PersistentState(context: Context) : SimpleKrate(context), KoinComponent {
// Allowed DNS record types (stored as comma-separated enum names)
// Default: A, AAAA, CNAME, HTTPS, SVCB, IPSECKEY
internal var allowedDnsRecordTypesString by stringPref("allowed_dns_record_types")
- .withDefault(setOf(
- ResourceRecordTypes.A.name,
- ResourceRecordTypes.AAAA.name,
- ResourceRecordTypes.CNAME.name,
- ResourceRecordTypes.HTTPS.name,
- ResourceRecordTypes.SVCB.name,
- ResourceRecordTypes.IPSECKEY.name
- ).joinToString(","))
+ .withDefault(defaultAllowedDnsRecordTypes())
// Auto mode for DNS record types - when enabled, all record types are allowed
// Default: true (Auto mode ON)
@@ -732,14 +765,14 @@ class PersistentState(context: Context) : SimpleKrate(context), KoinComponent {
// Custom LAN IPs. Store IP and prefix together as a single value (e.g., "10.111.222.1/24").
// Empty string means: use defaults.
- var customLanGatewayIpv4 by stringPref("custom_lan_gateway_ipv4").withDefault("10.111.222.1/24")
- var customLanGatewayIpv6 by stringPref("custom_lan_gateway_ipv6").withDefault("fd66:f83a:c650::1/120")
+ var customLanGatewayIpv4 by stringPref("custom_lan_gateway_ipv4").withDefault(DEFAULT_LAN_GATEWAY_IPV4)
+ var customLanGatewayIpv6 by stringPref("custom_lan_gateway_ipv6").withDefault(DEFAULT_LAN_GATEWAY_IPV6)
- var customLanRouterIpv4 by stringPref("custom_lan_router_ipv4").withDefault("10.111.222.2/32")
- var customLanRouterIpv6 by stringPref("custom_lan_router_ipv6").withDefault("fd66:f83a:c650::2/128")
+ var customLanRouterIpv4 by stringPref("custom_lan_router_ipv4").withDefault(DEFAULT_LAN_ROUTER_IPV4)
+ var customLanRouterIpv6 by stringPref("custom_lan_router_ipv6").withDefault(DEFAULT_LAN_ROUTER_IPV6)
- var customLanDnsIpv4 by stringPref("custom_lan_dns_ipv4").withDefault("10.111.222.3/32")
- var customLanDnsIpv6 by stringPref("custom_lan_dns_ipv6").withDefault("fd66:f83a:c650::3/128")
+ var customLanDnsIpv4 by stringPref("custom_lan_dns_ipv4").withDefault(DEFAULT_LAN_DNS_IPV4)
+ var customLanDnsIpv6 by stringPref("custom_lan_dns_ipv6").withDefault(DEFAULT_LAN_DNS_IPV6)
var customModeOrIpChanged by booleanPref("custom_lan_mode_ip_changed").withDefault(false)
@@ -775,10 +808,103 @@ class PersistentState(context: Context) : SimpleKrate(context), KoinComponent {
// the version of the guided tour that was last shown; used to re-trigger on UI changes
var guidedTourVersion by intPref("guided_tour_version").withDefault(0)
+ // whether the RPN (ServerSelection) onboarding tour has been completed;
+ // false = show the premium onboarding on the next dashboard visit
+ var rpnOnboardingCompleted by booleanPref("rpn_onboarding_completed").withDefault(false)
+
+ // the version of the RPN onboarding tour that was last shown
+ var rpnOnboardingVersion by intPref("rpn_onboarding_version").withDefault(0)
+
// maximum memory the go engine can consume in bytes (ideally value*1024*1024)
var goMaxMemory by longPref(GO_MAX_MEMORY).withDefault(-1L)
var blockDnsForUnknownApp by booleanPref("block_dns_for_unknown_app").withDefault(false)
var showRethinkBlockNotification by booleanPref("show_rethink_block_notification").withDefault(true)
+
+ private fun defaultAllowedDnsRecordTypes(): String {
+ return setOf(
+ ResourceRecordTypes.A.name,
+ ResourceRecordTypes.AAAA.name,
+ ResourceRecordTypes.CNAME.name,
+ ResourceRecordTypes.HTTPS.name,
+ ResourceRecordTypes.SVCB.name,
+ ResourceRecordTypes.IPSECKEY.name
+ ).joinToString(",")
+ }
+
+ /**
+ * Restores all settings shown on the DNS settings screen (DnsSettingsFragment)
+ * to their default values. Flavor (fdroid / play / website) and Android version
+ * dependent defaults (e.g., split DNS on Android R+, favicon on non-fdroid
+ * flavours, block-free DNS mode) are honored, mirroring the withDefault{} values
+ * of the respective properties.
+ */
+ fun restoreDnsSettingsDefaults() {
+ // flavour dependent: enabled on play / website, disabled on fdroid
+ fetchFavIcon = !Utilities.isFdroidFlavour()
+ enableDnsAlg = false
+ periodicallyCheckBlocklistUpdate = false
+ useCustomDownloadManager = true
+ enableDnsCache = true
+ useSystemDnsForUndelegatedDomains = false
+ blockDnsForUnknownApp = false
+ preventDnsLeaks = true
+ proxyDns = true
+ // android version dependent: split dns is default-on only on Android R and above
+ splitDns = isAtleastR()
+ // below Android R, split dns requires dns alg; keep both off (mirrors UI dependency)
+ if (!isAtleastR()) enableDnsAlg = false
+ blockFreeDns = ""
+ // android version dependent: AUTO on Android R+, FALLBACK below
+ blockFreeDnsMode = if (isAtleastR()) {
+ BlockFreeDnsModeBottomSheet.BlockFreeDnsMode.AUTO.mode
+ } else {
+ BlockFreeDnsModeBottomSheet.BlockFreeDnsMode.FALLBACK.mode
+ }
+ dnsRecordTypesAutoMode = true
+ allowedDnsRecordTypesString = defaultAllowedDnsRecordTypes()
+ }
+
+ /**
+ * Restores all settings shown on the tunnel settings screen (TunnelSettingsActivity)
+ * to their default values. Flavor (fdroid / play / website) dependent defaults
+ * (e.g., private IPs and connectivity checks on play-store builds) are honored,
+ * mirroring the withDefault{} values of the respective properties.
+ */
+ fun restoreTunnelSettingsDefaults() {
+ useMultipleNetworks = false
+ // flavour dependent: on by default only on play-store builds
+ privateIps = Utilities.isPlayStoreFlavour()
+ excludeAppsInProxy = true
+ protocolTranslationType = false
+ treatOnlyMobileNetworkAsMetered = false
+ stallOnNoNetwork = false
+ randomizeListenPort = true
+ wgGlobalLockdown = false
+ floodWireGuard = false
+ smartPersistentKeepalive = false
+ endpointIndependence = false
+ nwEngExperimentalFeatures = false
+ tcpKeepAlive = false
+ dialTimeoutSec = 0
+ socketBufferSizeBytes = DEFAULT_SOCKET_BUFFER_SIZE_BYTES
+ useMaxMtu = false
+ setVpnBuilderToMetered = false
+ vpnBuilderPolicy = DEFAULT_VPN_BUILDER_POLICY
+ internetProtocolType = InternetProtocol.IPv4.id
+ defaultDnsUrl = Constants.DEFAULT_DNS_LIST[1].url
+ // flavour dependent: on by default only on play-store builds
+ connectivityChecks = Utilities.isPlayStoreFlavour()
+ performAutoNetworkConnectivityChecks = true
+ routeRethinkInRethink = false
+ customLanIpMode = false
+ customModeOrIpChanged = false
+ customLanGatewayIpv4 = DEFAULT_LAN_GATEWAY_IPV4
+ customLanGatewayIpv6 = DEFAULT_LAN_GATEWAY_IPV6
+ customLanRouterIpv4 = DEFAULT_LAN_ROUTER_IPV4
+ customLanRouterIpv6 = DEFAULT_LAN_ROUTER_IPV6
+ customLanDnsIpv4 = DEFAULT_LAN_DNS_IPV4
+ customLanDnsIpv6 = DEFAULT_LAN_DNS_IPV6
+ }
}
diff --git a/app/src/main/java/com/celzero/bravedns/service/ProxyManager.kt b/app/src/main/java/com/celzero/bravedns/service/ProxyManager.kt
index ec935cce62..0ae037c10f 100644
--- a/app/src/main/java/com/celzero/bravedns/service/ProxyManager.kt
+++ b/app/src/main/java/com/celzero/bravedns/service/ProxyManager.kt
@@ -26,6 +26,8 @@ import com.celzero.firestack.backend.Backend
import com.celzero.firestack.backend.RouterStats
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
import java.util.concurrent.CopyOnWriteArraySet
object ProxyManager : KoinComponent {
@@ -93,7 +95,15 @@ object ProxyManager : KoinComponent {
private val pamSet = CopyOnWriteArraySet()
+ // mutex protecting all proxy-app mapping mutations and refresh maintenance from
+ // concurrent updates
+ private val pamMutex = Mutex()
+
suspend fun load(): Int {
+ pamMutex.withLock { return loadLocked() }
+ }
+
+ private suspend fun loadLocked(): Int {
val a = db.getApps()
val entries = a.map { ProxyAppMapTuple(it.uid, it.packageName, it.proxyId) }
pamSet.clear()
@@ -118,6 +128,7 @@ object ProxyManager : KoinComponent {
// ensure the proxyId="" base row exists for (uid, packageName). base row is what makes an
// app visible in the "All Apps" pager query (proxyId = :proxyId OR proxyId = '') even when the
// app is not assigned to any specific proxy.
+ // Caller must hold pamMutex (all call sites are within withLock blocks).
private suspend fun ensureBaseRow(uid: Int, packageName: String, appName: String) {
val baseTuple = ProxyAppMapTuple(uid, packageName, "")
if (pamSet.contains(baseTuple)) return
@@ -132,22 +143,26 @@ object ProxyManager : KoinComponent {
Logger.e(LOG_TAG_PROXY, "Invalid proxy id: $proxyId")
return
}
- // FirewallManager is the source of truth for "all apps"; iterate it instead of the proxy
- // cache (trackedApps) so that apps missing from the proxy mapping are also backfilled.
- val toAdd = FirewallManager.getAllApps()
- toAdd.forEach { app ->
- val ai = FirewallManager.getAppInfoByUidAndPackage(app.uid, app.packageName)
- ?: return@forEach
-
- if (ai.tombstoneTs > 0L) return@forEach // skip uninstalled/tombstoned
- // concurrently removed; will be reconciled on next refresh
- // make sure the app is visible in app-lists even if not assigned to a proxy
- ensureBaseRow(app.uid, app.packageName, ai.appName)
- val tuple = ProxyAppMapTuple(app.uid, app.packageName, proxyId)
- if (!pamSet.contains(tuple)) {
- pamSet.add(tuple)
- val pam = ProxyApplicationMapping(app.uid, app.packageName, ai.appName, proxyName, true, proxyId)
- db.insert(pam)
+ pamMutex.withLock {
+ // FirewallManager is the source of truth for "all apps"; iterate it instead of the
+ // proxy cache (trackedApps) so that apps missing from the proxy mapping are also
+ // backfilled.
+ val toAdd = FirewallManager.getAllApps()
+ toAdd.forEach { app ->
+ val ai = FirewallManager.getAppInfoByUidAndPackage(app.uid, app.packageName)
+ ?: return@forEach
+
+ if (ai.tombstoneTs > 0L) return@forEach // skip uninstalled/tombstoned
+ // concurrently removed; will be reconciled on next refresh
+ // make sure the app is visible in app-lists even if not assigned to a proxy
+ ensureBaseRow(app.uid, app.packageName, ai.appName)
+ val tuple = ProxyAppMapTuple(app.uid, app.packageName, proxyId)
+ if (!pamSet.contains(tuple)) {
+ pamSet.add(tuple)
+ val pam =
+ ProxyApplicationMapping(app.uid, app.packageName, ai.appName, proxyName, true, proxyId)
+ db.insert(pam)
+ }
}
}
Logger.i(LOG_TAG_PROXY, "added proxy $proxyId to all apps")
@@ -163,13 +178,19 @@ object ProxyManager : KoinComponent {
Logger.e(LOG_TAG_PROXY, "Invalid proxy id: $proxyId")
return
}
- // add this proxy only to apps that do not yet have it. Iterate FirewallManager (source of
- // truth) instead of the proxy cache so missing apps are backfilled too.
- val toAdd = FirewallManager.getAllApps()
- toAdd.forEach { app ->
- val ai = FirewallManager.getAppInfoByUidAndPackage(app.uid, app.packageName) ?: return@forEach
- val existing = pamSet.any { it.uid == app.uid && it.packageName == app.packageName && it.proxyId == proxyId }
- if (!existing) {
+ pamMutex.withLock {
+ // "remaining apps" = apps not routed by ANY proxy yet. Apps already assigned to
+ // another proxy must be skipped, otherwise this call inserts new rows into
+ // ProxyApplicationMapping for apps that never opted into this proxy.
+ val toAdd = FirewallManager.getAllApps()
+ toAdd.forEach { app ->
+ val ai =
+ FirewallManager.getAppInfoByUidAndPackage(app.uid, app.packageName) ?: return@forEach
+ if (ai.tombstoneTs > 0L) return@forEach // skip uninstalled/tombstoned
+ val hasAnyProxy = pamSet.any {
+ it.uid == app.uid && it.packageName == app.packageName && it.proxyId.isNotEmpty()
+ }
+ if (hasAnyProxy) return@forEach
// ensure visibility even if the base row was lost
ensureBaseRow(app.uid, app.packageName, ai.appName)
pamSet.add(ProxyAppMapTuple(app.uid, app.packageName, proxyId))
@@ -181,13 +202,15 @@ object ProxyManager : KoinComponent {
}
suspend fun setNoProxyForAllAppsForProxy(proxyId: String) {
- // remove only this proxyId from every app
- val toRemove = pamSet.filter { it.proxyId == proxyId }.toSet()
- if (toRemove.isEmpty()) return
- pamSet.removeAll(toRemove)
- // delete only the rows for this proxy from DB
- toRemove.forEach {
- db.deleteMapping(it.uid, it.packageName, it.proxyId)
+ pamMutex.withLock {
+ // remove only this proxyId from every app
+ val toRemove = pamSet.filter { it.proxyId == proxyId }.toSet()
+ if (toRemove.isEmpty()) return@withLock
+ pamSet.removeAll(toRemove)
+ // delete only the rows for this proxy from DB
+ toRemove.forEach {
+ db.deleteMapping(it.uid, it.packageName, it.proxyId)
+ }
}
Logger.i(LOG_TAG_PROXY, "removed proxy $proxyId from all apps")
}
@@ -210,23 +233,33 @@ object ProxyManager : KoinComponent {
}
suspend fun deleteApps(m: Collection) {
- m.forEach { deleteApp(it.uid, it.packageName) }
+ pamMutex.withLock {
+ m.forEach { deleteAppLocked(it.uid, it.packageName) }
+ }
}
suspend fun addApps(m: Collection) {
- m.forEach { addNewApp(it) }
+ pamMutex.withLock {
+ m.forEach { addNewAppLocked(it) }
+ }
}
suspend fun updateApps(m: Collection) {
- m.forEach {
- val newInfo = FirewallManager.getAppInfoByPackage(it.packageName) ?: return@forEach
- if (newInfo.uid == it.uid) return@forEach // no change in uid
+ pamMutex.withLock {
+ m.forEach {
+ val newInfo = FirewallManager.getAppInfoByPackage(it.packageName) ?: return@forEach
+ if (newInfo.uid == it.uid) return@forEach // no change in uid
- updateApp(newInfo.uid, it.packageName)
+ updateAppLocked(newInfo.uid, it.packageName)
+ }
}
}
suspend fun updateApp(uid: Int, packageName: String) {
+ pamMutex.withLock { updateAppLocked(uid, packageName) }
+ }
+
+ private suspend fun updateAppLocked(uid: Int, packageName: String) {
// filter only entries with a different uid; these are the stale ones
val m = pamSet.filter { it.packageName == packageName && it.uid != uid }.toSet()
if (m.isEmpty()) {
@@ -263,6 +296,10 @@ object ProxyManager : KoinComponent {
}
suspend fun addNewApp(appInfo: AppInfo?, proxyId: String = "", proxyName: String = "") {
+ pamMutex.withLock { addNewAppLocked(appInfo, proxyId, proxyName) }
+ }
+
+ private suspend fun addNewAppLocked(appInfo: AppInfo?, proxyId: String = "", proxyName: String = "") {
if (appInfo == null) {
Logger.e(LOG_TAG_PROXY, "AppInfo is null, cannot add to proxy")
return
@@ -294,60 +331,73 @@ object ProxyManager : KoinComponent {
}
suspend fun deleteApp(uid: Int, packageName: String) {
+ pamMutex.withLock { deleteAppLocked(uid, packageName) }
+ }
+
+ // Caller must hold pamMutex.
+ private suspend fun deleteAppLocked(uid: Int, packageName: String) {
deleteFromCache(uid, packageName)
db.deleteApp(uid, packageName)
Logger.i(LOG_TAG_PROXY, "deleting app for mapping: $uid, $packageName")
}
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}")
+ pamMutex.withLock {
+ val fm = FirewallManager.getAppInfoByPackage(packageName)
+ // if there is no app info for the package, then delete the app from the mapping
+ if (fm == null) {
+ deleteAppLocked(uid, packageName)
+ return@withLock
+ } 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 deleteAppByPkgName(packageName: String) {
- val toRemove = pamSet.filter { it.packageName == packageName }
- if (toRemove.isEmpty()) {
- Logger.i(LOG_TAG_PROXY, "deleteAppByPkgName: app not found in proxy mapping: $packageName")
- return
+ pamMutex.withLock {
+ val toRemove = pamSet.filter { it.packageName == packageName }
+ if (toRemove.isEmpty()) {
+ Logger.i(LOG_TAG_PROXY, "deleteAppByPkgName: app not found in proxy mapping: $packageName")
+ return@withLock
+ }
+ pamSet.removeAll(toRemove.toSet())
+ // delete the app from the database
+ db.deleteAppByPkgName(packageName)
+ Logger.i(LOG_TAG_PROXY, "deleting app for mapping by package name: $packageName")
}
- pamSet.removeAll(toRemove.toSet())
- // delete the app from the database
- db.deleteAppByPkgName(packageName)
- Logger.i(LOG_TAG_PROXY, "deleting app for mapping by package name: $packageName")
}
suspend fun clear() {
- pamSet.clear()
- db.deleteAll()
- Logger.d(LOG_TAG_PROXY, "deleting all apps for mapping")
+ pamMutex.withLock {
+ pamSet.clear()
+ db.deleteAll()
+ Logger.d(LOG_TAG_PROXY, "deleting all apps for mapping")
+ }
}
suspend fun tombstoneApp(oldUid: Int) {
- val newUid = if (oldUid > 0) -1 * oldUid else oldUid
- if (newUid == oldUid) {
- Logger.w(LOG_TAG_PROXY, "no change in uid, not tombstoning: $oldUid")
- return
- }
- val entries = pamSet.filter { it.uid == oldUid }
- try {
- entries.forEach { tuple ->
- db.deleteMapping(newUid, tuple.packageName, tuple.proxyId)
+ pamMutex.withLock {
+ val newUid = if (oldUid > 0) -1 * oldUid else oldUid
+ if (newUid == oldUid) {
+ Logger.w(LOG_TAG_PROXY, "no change in uid, not tombstoning: $oldUid")
+ return@withLock
}
- db.tombstoneApp(oldUid, newUid)
- } catch (e: Exception) {
- Logger.w(LOG_TAG_PROXY, "tombstoneApp failed for oldUid=$oldUid; reloading cache", e)
- load()
- return
+ val entries = pamSet.filter { it.uid == oldUid }
+ try {
+ entries.forEach { tuple ->
+ db.deleteMapping(newUid, tuple.packageName, tuple.proxyId)
+ }
+ db.tombstoneApp(oldUid, newUid)
+ } catch (e: Exception) {
+ Logger.w(LOG_TAG_PROXY, "tombstoneApp failed for oldUid=$oldUid; reloading cache", e)
+ loadLocked()
+ return@withLock
+ }
+ loadLocked()
+ Logger.i(LOG_TAG_PROXY, "tombstoning app for mapping: $oldUid, $newUid, entries: ${entries.size}")
}
- load()
- Logger.i(LOG_TAG_PROXY, "tombstoning app for mapping: $oldUid, $newUid, entries: ${entries.size}")
}
fun isAnyAppSelected(proxyId: String): Boolean {
@@ -454,18 +504,23 @@ object ProxyManager : KoinComponent {
Logger.e(LOG_TAG_PROXY, "cannot add invalid proxy id: $proxyId")
return
}
- val tuple = ProxyAppMapTuple(uid, packageName, proxyId)
- if (pamSet.contains(tuple)) return
- pamSet.add(tuple)
- val appName = FirewallManager.getAppInfoByPackage(packageName)?.appName.orEmpty()
- val pam = ProxyApplicationMapping(uid, packageName, appName, proxyName, true, proxyId)
- db.insert(pam)
+ pamMutex.withLock {
+ val tuple = ProxyAppMapTuple(uid, packageName, proxyId)
+ if (pamSet.contains(tuple)) return@withLock
+ pamSet.add(tuple)
+ val appName = FirewallManager.getAppInfoByPackage(packageName)?.appName.orEmpty()
+ val pam = ProxyApplicationMapping(uid, packageName, appName, proxyName, true, proxyId)
+ db.insert(pam)
+ }
}
suspend fun removeProxyFromApp(uid: Int, packageName: String, proxyId: String) {
- val toRemove = pamSet.filter { it.uid == uid && it.packageName == packageName && it.proxyId == proxyId }
- if (toRemove.isEmpty()) return
- pamSet.removeAll(toRemove.toSet())
- db.deleteMapping(uid, packageName, proxyId)
+ pamMutex.withLock {
+ val toRemove =
+ pamSet.filter { it.uid == uid && it.packageName == packageName && it.proxyId == proxyId }
+ if (toRemove.isEmpty()) return@withLock
+ pamSet.removeAll(toRemove.toSet())
+ db.deleteMapping(uid, packageName, proxyId)
+ }
}
}
diff --git a/app/src/main/java/com/celzero/bravedns/service/QuickSettingsTileHelper.kt b/app/src/main/java/com/celzero/bravedns/service/QuickSettingsTileHelper.kt
new file mode 100644
index 0000000000..b8aad41da5
--- /dev/null
+++ b/app/src/main/java/com/celzero/bravedns/service/QuickSettingsTileHelper.kt
@@ -0,0 +1,47 @@
+/*
+Copyright 2026 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.service
+
+import android.content.ComponentName
+import android.content.Context
+import android.service.quicksettings.TileService
+import com.celzero.bravedns.util.Logger
+import com.celzero.bravedns.util.Utilities
+
+/**
+ * Stands between callers and the API-24-only quick-settings tile machinery.
+ *
+ * fix lint error
+ */
+object QuickSettingsTileHelper {
+
+ // rebind the tile so onStartListening() can get its current state. tile
+ // updates pushed while the tile is not listening are silently dropped by
+ // SystemUI; without this push, the tile keeps showing its last state.
+ fun requestTileUpdate(context: Context) {
+ // TileService.requestListeningState() is API 24+; minSdk is 23.
+ if (!Utilities.isAtleastN()) return
+ try {
+ TileService.requestListeningState(
+ context,
+ ComponentName(context, BraveTileService::class.java)
+ )
+ } catch (e: Exception) {
+ Logger.w(Logger.LOG_TAG_VPN, "Tile: err in requesting listening state", e)
+ }
+ }
+}
diff --git a/app/src/main/java/com/celzero/bravedns/service/RethinkBlocklistManager.kt b/app/src/main/java/com/celzero/bravedns/service/RethinkBlocklistManager.kt
index 4a1eb09e69..500c8ff4b8 100644
--- a/app/src/main/java/com/celzero/bravedns/service/RethinkBlocklistManager.kt
+++ b/app/src/main/java/com/celzero/bravedns/service/RethinkBlocklistManager.kt
@@ -347,28 +347,6 @@ object RethinkBlocklistManager : KoinComponent {
localFileTagRepository.clearSelectedTags()
}
- fun cpSelectFileTag(localFileTags: RethinkLocalFileTag): Int {
- io {
- val selectedTags =
- getTagsFromStamp(persistentState.localBlocklistStamp, RethinkBlocklistType.LOCAL)
- .toMutableSet()
-
- // remove the tag from the local blocklist if it exists and current selection is 0
- if (selectedTags.contains(localFileTags.value) && !localFileTags.isSelected) {
- selectedTags.remove(localFileTags.value)
- } else if (!selectedTags.contains(localFileTags.value) && localFileTags.isSelected) {
- // only add the tag if it is not already present
- selectedTags.add(localFileTags.value)
- } else {
- // no-op
- }
-
- val stamp = getStamp(selectedTags, RethinkBlocklistType.LOCAL)
- persistentState.localBlocklistStamp = stamp
- }
- return localFileTagRepository.contentUpdate(localFileTags)
- }
-
suspend fun getStamp(fileValues: Set, type: RethinkBlocklistType): String {
if (fileValues.isEmpty()) return ""
diff --git a/app/src/main/java/com/celzero/bravedns/service/ServiceModule.kt b/app/src/main/java/com/celzero/bravedns/service/ServiceModule.kt
index b3d7385605..fec47f8b25 100644
--- a/app/src/main/java/com/celzero/bravedns/service/ServiceModule.kt
+++ b/app/src/main/java/com/celzero/bravedns/service/ServiceModule.kt
@@ -24,6 +24,7 @@ object ServiceModule {
private val serviceModules = module {
single { PersistentState(androidContext()) }
single { EventLogger(get()) }
+ single { LogActivityAggregator(get(), get(), get()) }
single { NetLogTracker(androidContext(), get(), get(), get(), get(), get()) }
single { RefreshDatabase(androidContext(), get(), get(), get(), get(), get()) }
// SecureIdentityStore: encrypted file-backed store for accountId + deviceId.
diff --git a/app/src/main/java/com/celzero/bravedns/service/TunFlowManager.kt b/app/src/main/java/com/celzero/bravedns/service/TunFlowManager.kt
index f0a9a37316..fda712ddfe 100644
--- a/app/src/main/java/com/celzero/bravedns/service/TunFlowManager.kt
+++ b/app/src/main/java/com/celzero/bravedns/service/TunFlowManager.kt
@@ -38,6 +38,9 @@ import com.celzero.bravedns.net.manager.ConnectionTracer
import com.celzero.bravedns.receiver.NotificationActionReceiver
import com.celzero.bravedns.rpnproxy.RpnProxyManager
import com.celzero.bravedns.service.FirewallManager.NOTIF_CHANNEL_ID_FIREWALL_ALERTS
+import com.celzero.bravedns.service.LogActivityAggregator
+import com.celzero.bravedns.service.LogActivityEvent
+import com.celzero.bravedns.service.LogActivitySource
import com.celzero.bravedns.service.ProxyManager.ID_WG_BASE
import com.celzero.bravedns.service.ProxyManager.isNotLocalAndRpnProxy
import com.celzero.bravedns.util.AndroidUidConfig
@@ -125,6 +128,7 @@ object TunFlowManager : KoinComponent {
private val appConfig by inject()
private val persistentState by inject()
private val netLogTracker by inject