Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
046427b
docs: mark this as a fork of celzero/rethink-app for Android TV
rootshel May 10, 2026
ea2cd8d
feat(tv): add Android TV product flavor scaffold
rootshel May 10, 2026
8c5e6b1
ci(tv): enable --stacktrace on TV CI build
rootshel May 10, 2026
43b5b9a
fix(tv): inherit full/ source set to resolve KSP Room error
rootshel May 10, 2026
33e3893
feat(tv): add signed-release pipeline for fdroidTvRelease
rootshel May 10, 2026
c61312f
fix(tv): replace backtick-escapes in build.gradle release log strings
rootshel May 10, 2026
c992906
feat(tv): add upstream-sync tooling (script + workflow + docs)
rootshel May 10, 2026
8e56a7e
ci(tv): add workflow_dispatch trigger to Android TV CI
rootshel May 10, 2026
3f5fb4e
feat(tv): Compose-for-TV launcher with VPN start/stop (Phase 5 MVP)
rootshel May 10, 2026
8a6d7ad
feat(tv): add Tab nav with Home / Settings / About destinations
rootshel May 10, 2026
ecf2e00
feat(tv): Streams tab — per-app VPN bypass for streamers
rootshel May 10, 2026
40aebba
chore(sync): warn on AndroidManifest drift between full/ and tv/
rootshel May 10, 2026
4a57b25
fix(tv): live UI refresh on Streams + initial focus on tab row
rootshel May 10, 2026
60cb86e
feat(tv): reset UI scaffold and add 8-destination nav skeleton
rootshel May 18, 2026
82fdfde
feat(tv,home): dashboard cards — status, DNS, counters
rootshel May 18, 2026
4074269
feat(tv,firewall): universal firewall toggles
rootshel May 18, 2026
7f5c60e
feat(tv,settings): expand to tunnel / wg / reliability / boot toggles
rootshel May 18, 2026
3fab82c
feat(tv,apps): per-app firewall — grid + detail with status selectors
rootshel May 18, 2026
79590e0
feat(tv,dns): encrypted DNS picker — DoH / DoT / ODoH tabs
rootshel May 18, 2026
fbab751
feat(tv,logs): connection & DNS log viewer via paging-compose
rootshel May 18, 2026
c3fd2d4
feat(tv,proxy): WireGuard tunnel list + SOCKS5/HTTP/Orbot status
rootshel May 18, 2026
3aff103
feat(tv,stats): top apps / domains / IPs / blocked tabs
rootshel May 18, 2026
d7c916c
feat(tv,polish): first-run welcome banner + consent-failure toast
rootshel May 18, 2026
e3dba87
feat(tv,logs): free-text search + per-app filter
rootshel May 18, 2026
612170a
feat(tv,rules): custom domain + IP rule lists
rootshel May 18, 2026
60f5640
feat(tv,proxy): WireGuard tunnel detail + SOCKS5/HTTP editors
rootshel May 18, 2026
4980f75
feat(tv,parity): wave 3 — diagnostics, drill-downs, custom endpoints,…
rootshel May 19, 2026
13f8914
fix(tv,nav): replace broken tv-material NavigationDrawer with hand-ro…
rootshel May 20, 2026
7df8a8a
fix(tv,nav): anchor rail focus on parent destination after sub-screen…
rootshel May 20, 2026
342d4e6
tv: make Surface respond to mouse/touch taps
rootshel May 20, 2026
212b033
Merge remote-tracking branch 'upstream/main'
rootshel May 23, 2026
301c354
tv: add compose-runtime to base implementation so phone build keeps c…
rootshel May 23, 2026
59ef6e6
tv: fix VPN start crash + keep focus on the Home toggle button
rootshel May 23, 2026
387e9cc
tv-variant: drop fork-only infra so the diff is upstream-ready
rootshel May 23, 2026
59d2c9c
tv: empty taskAffinity on TvHomeActivity (StrandHogg mitigation)
rootshel Jun 5, 2026
0f9eb43
tv: move sources under com.celzero.bravedns.tv
rootshel Aug 6, 2026
a8db922
Merge remote-tracking branch 'upstream/main' into tv-variant
rootshel Aug 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .github/workflows/android-tv.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: 📺 Android TV CI

# rethink-tv fork: builds the `tv` Gradle flavor on every push and PR.
# This workflow lives alongside upstream's `android.yml` (which builds
# the phone variant) and does not modify it, keeping the upstream-sync
# diff minimal.

on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
# Allow ad-hoc validation of feature branches (e.g. `for-upstream`,
# `upstream-sync`) without changing the regular trigger surface.
workflow_dispatch:

jobs:
build-tv:
Comment on lines +15 to +18

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[other · low]
The workflow triggers on both push and pull_request to main but has no concurrency group. Rapid successive commits to the same branch/PR can launch redundant, simultaneous builds that waste CI minutes. Add a concurrency group keyed to the workflow and ref with cancel-in-progress: true so in-progress runs of the same branch are cancelled.

Suggestion:

Suggested change
workflow_dispatch:
jobs:
build-tv:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-tv:

runs-on: ubuntu-latest
Comment on lines +17 to +19

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[security · medium]
The job does not declare a permissions block, so the implicit GITHUB_TOKEN inherits the repository's default scope (which may be read/write). Since this workflow is triggered by pull_request and executes untrusted PR build scripts, a malicious build could use an over-scoped token to modify repository contents. This also deviates from the repo's own convention — mobsf.yml, codeql.yml, sa.yml, and scorecard.yml all declare explicit least-privilege permissions. Add a minimal declaration (e.g. contents: read) at the workflow or job level; the steps here only need to check out code and upload an artifact.

Suggestion:

Suggested change
jobs:
build-tv:
runs-on: ubuntu-latest
jobs:
build-tv:
permissions:
contents: read
runs-on: ubuntu-latest

env:
Comment on lines +19 to +20

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[other · low]
The job has no timeout-minutes. A hung Gradle build or stuck dependency download can consume runner time indefinitely (GitHub-hosted runners have a default, but it's long). Add an explicit timeout-minutes (e.g. 30) so runaway builds are bounded and CI stays predictable.

Suggestion:

Suggested change
runs-on: ubuntu-latest
env:
runs-on: ubuntu-latest
timeout-minutes: 30
env:

VARIANT: assembleFdroidTvDebug
steps:
- name: 🥏 Checkout
uses: actions/checkout@v6
Comment on lines +23 to +24

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[other · low]
app/build.gradle derives versionName = gitVersion from git describe --tags --always. The default actions/checkout@v6 does a shallow (depth-1) fetch without tags, so this command falls back to a bare commit SHA and the produced debug APK gets a non-tag versionName. Since this CI exists to validate and distribute the TV builds, add fetch-depth: 0 so the tag-based version name resolves correctly.

Suggestion:

Suggested change
- name: 🥏 Checkout
uses: actions/checkout@v6
- name: 🥏 Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0


- name: ☕️ JDKv17
uses: actions/setup-java@v5
with:
java-version: '17'
distribution: 'temurin'
cache: gradle

- name: ⚡️ +x gradlew
run: chmod +x gradlew

- name: 🚂 Assemble TV variant
run: |
./gradlew \
${VARIANT} \
--info \
--stacktrace \
--warning-mode all
env:
VARIANT: ${{ env.VARIANT }}

- name: 📦 Upload TV debug APK
if: success()
uses: actions/upload-artifact@v4
with:
name: rethink-tv-fdroid-debug
path: app/build/outputs/apk/fdroidTv/debug/*.apk
if-no-files-found: warn
retention-days: 14
183 changes: 183 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ plugins {
id 'com.android.application'
id 'com.google.devtools.ksp'
id 'kotlin-android'
// rethink-tv fork: Compose Compiler plugin for the `tv` flavor's
// Compose-for-TV UI. Safe to apply project-wide — phone variants
// contain no @Composable and the plugin then no-ops.
id 'org.jetbrains.kotlin.plugin.compose'
// to generate BOM in CycloneDX format
// ./gradlew cyclonedxBom
// id 'org.cyclonedx.bom' version '3.2.4'
Expand Down Expand Up @@ -296,6 +300,11 @@ android {
buildFeatures {
viewBinding true
buildConfig true
// rethink-tv fork: Compose UI for the `tv` flavor. Project-wide so
// the Compose Compiler picks up @Composable declarations under
// `app/src/tv/`. Phone variants have no @Composable code so this
// costs nothing at compile time.
compose true
}

compileOptions {
Expand Down Expand Up @@ -340,10 +349,85 @@ android {
buildConfigField "int", "BASE_VERSION_CODE", String.valueOf(appVersionCode)
vectorDrawables.useSupportLibrary = true
}
// Android TV flavor (rethink-tv fork addition).
// Peer of `full` in the releaseType dimension. Inherits the entire
// upstream `app/src/full/` source set (java + res + manifest) so the
// engine and shared `app/src/main/` code compile unchanged. Phase 2
// intentionally reuses upstream's phone UI to validate the build /
// distribution pipeline; the dedicated Compose-for-TV navigation host
// is introduced in a subsequent phase (`tv-ux-dashboard`). The TV
// override surface lives in `app/src/tv/` and is layered on top of
// `full/` via the `sourceSets` block below. See README for the fork
// notice and `docs/` for the upstream-sync workflow.
tv {
dimension "releaseType"
applicationIdSuffix ".tv"
versionCode = getVersionCode()
versionName = gitVersion
buildConfigField "int", "BASE_VERSION_CODE", String.valueOf(appVersionCode)
vectorDrawables.useSupportLibrary = true
}
}

// The `tv` flavor inherits the entire `full` source set so that engine /
// service / view-model classes upstream keeps in `app/src/full/` (which
// shared `app/src/main/` code references via constants like `ID_WG_BASE`)
// are visible to the `tv` variants. The `app/src/tv/` source set then
// layers TV-specific resources (e.g. `strings.xml` to rename the app to
// "Rethink TV") and a TV launcher Activity on top via the standard
// Android resource / manifest overlay rules. `src/tv/AndroidManifest.xml`
// is the flavor's primary manifest — see that file for the rationale
// (it currently mirrors full's manifest and adds the TV launcher).
sourceSets {
tv {
java.srcDirs += ['src/full/java']
res.srcDirs += ['src/full/res']
}
Comment on lines +382 to +385

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
sourceSets.tv only layers java and res from the full flavor — it does not include src/full/AndroidManifest.xml in the manifest merge. That means app/src/tv/AndroidManifest.xml must be manually kept in sync with full's manifest, and it is already drifting: full's manifest declares .ui.activity.BlockFreeDnsActivity (which is also compiled into tv variants via the inherited src/full/java), but the tv manifest omits it. Any inherited code path that launches that activity will crash with ActivityNotFoundException on tv builds. Consider merging full's manifest into the tv source set (manifest.srcFile 'src/full/AndroidManifest.xml' plus overlay) or adding an automated drift check between the two manifests.

}
lint {
abortOnError true
}

// ------------------------------------------------------------------
// rethink-tv fork: env-var-based signing for the `release` build type
// when used to ship TV builds via GitHub Actions.
//
// When the CI workflow `.github/workflows/android-tv-release.yml`
// injects the `TV_RELEASE_KS_*` secrets, we:
// 1. create a `tvRelease` signing config that reads those env vars
// (same shape as upstream's `alpha` signing config — see line 101
// of this file)
// 2. attach it to the `release` build type so that
// `assembleFdroidTvRelease` produces a signed APK suitable for
// attaching to a GitHub Release.
//
// When the env vars are NOT set (local builds, `🫣 Android CI`, the
// `📺 Android TV CI` debug job, manual unsigned-release dry runs):
// - upstream's existing `keystore.properties`-based `config` signing
// config remains the only signing config that touches `release`,
// preserving 100% upstream behaviour for phone builds.
//
// This block intentionally does not modify upstream's existing signing
// configs or build types — it only conditionally appends.
// ------------------------------------------------------------------
def tvKsAlias = System.getenv("TV_RELEASE_KS_ALIAS")
def tvKsPassphrase = System.getenv("TV_RELEASE_KS_PASSPHRASE")
def tvKsFile = System.getenv("TV_RELEASE_KS_FILE")
def tvKsStorePassphrase = System.getenv("TV_RELEASE_KS_STORE_PASSPHRASE")
if (tvKsAlias && tvKsPassphrase && tvKsFile && tvKsStorePassphrase) {
signingConfigs {
tvRelease {
keyAlias tvKsAlias
keyPassword tvKsPassphrase
storeFile file(tvKsFile)
storePassword tvKsStorePassphrase
}
}
buildTypes.release.signingConfig = signingConfigs.tvRelease

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
This conditional override applies signingConfigs.tvRelease to the global release build type, i.e. to every release variant built in the same Gradle invocation — including phone variants such as assembleFdroidFullRelease. If the TV_RELEASE_KS_* secrets happen to be present in the environment while a phone release is built (combined CI job, or a local shell exporting them), the phone APK would be silently signed with the TV keystore. The override should be scoped to the tv flavor only (e.g. via android.applicationVariants.all { ... } filtering the variant's flavor, or a tv-only task), not assigned to buildTypes.release globally. Note also that the comment references .github/workflows/android-tv-release.yml, but the workflow added in this change set is android-tv.yml (a debug-only job), so this block is currently dead code whose behavior is untested.

println("rethink-tv: TV_RELEASE_KS_* env vars detected; 'release' build type will be signed with signingConfigs.tvRelease")
} else {
println("rethink-tv: TV_RELEASE_KS_* env vars NOT set; 'release' builds will be unsigned (upstream default)")
}
Comment on lines +413 to +430

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate AGP version and any existing per-variant signing assignments for reference.
rg -n "com.android.tools.build:gradle" build.gradle
rg -n "signingConfig" --glob '*.gradle' -C2

Repository: celzero/rethink-app

Length of output: 218


🏁 Script executed:

#!/bin/bash
set -u

printf '%s\n' '--- app/build.gradle signing/flavor context ---'
sed -n '360,450p' app/build.gradle

printf '%s\n' '--- Android plugin and variant API references ---'
rg -n -C3 'com\.android\.tools\.build:gradle|androidComponents|applicationVariants|variant\.|flavorDimensions|productFlavors|signingConfig' \
  build.gradle settings.gradle gradle.properties app --glob '*.gradle' --glob '*.gradle.kts' || true

printf '%s\n' '--- relevant flavor declarations ---'
rg -n -C8 'fdroidTv|fdroidFull|playFull|websiteFull|productFlavors|flavorDimensions' app/build.gradle

Repository: celzero/rethink-app

Length of output: 11190


🏁 Script executed:

#!/bin/bash
set -u

printf '%s\n' '--- complete flavor and build-type declarations ---'
sed -n '220,370p' app/build.gradle

printf '%s\n' '--- workflow variant and signing environment usage ---'
rg -n -C5 'assemble|bundle|TV_RELEASE_KS_|fdroidTv|playFull|websiteFull|fdroidFull' .github app README.md docs --glob '*.yml' --glob '*.yaml' --glob '*.gradle' --glob '*.md' 2>/dev/null || true

printf '%s\n' '--- Gradle wrapper and local AGP-related sources ---'
cat gradle/wrapper/gradle-wrapper.properties 2>/dev/null || true
find . -path '*/node_modules' -prune -o -path '*/build' -prune -o -type f \\( -iname '*variant*' -o -iname '*signing*' \\) -print 2>/dev/null | head -100

Repository: celzero/rethink-app

Length of output: 16927


🌐 Web query:

Android Gradle Plugin 8.13 Variant API per-variant signingConfig assign signing config Groovy

💡 Result:

In the Android Gradle Plugin (AGP) 8.13, the Variant API allows you to access and configure signing properties for specific variants [1][2]. However, the SigningConfig object provided by the Variant API is primarily intended for inspecting or modifying specific signing properties (such as enabling/disabling V1, V2, V3, or V4 signatures) rather than assigning an entirely new signing configuration object created in the DSL [3][1]. To apply a different signing configuration to a specific variant in your build.gradle (Groovy) file, you should typically use the android {... } block to define your signing configs and then assign them to the desired build types or product flavors within the Android DSL [4]. If you need to programmatically modify the signing configuration of a variant at configuration time using the Variant API, you access it through the androidComponents extension [1][5]: androidComponents { onVariants(selector.all, { variant -> // Access the signingConfig property of the variant def signing = variant.signingConfig // Example: Programmatically enable or disable signature schemes signing.enableV4Signing.set(true) }) } It is important to note that the signingConfig property on the ApplicationVariant object returns a SigningConfig instance that is initialized by the corresponding DSL element [1][6]. If you intend to change the actual store file or passwords for a specific variant, the standard approach remains defining multiple signingConfigs in the android { signingConfigs {... } } block and assigning the appropriate one to the relevant buildType or flavor [4]. The Variant API's setConfig function, marked as @Incubating, is used for advanced, programmatic adjustment of signing information [3][7].

Citations:


🌐 Web query:

site:developer.android.com/reference/tools/gradle-api/8.13/com/android/build/api/variant/VariantBuilder signingConfig

💡 Result:

In Android development, signingConfig is a Domain Specific Language (DSL) object used within Gradle build scripts to configure the signing settings for your application, such as APKs and bundles [1][2]. It allows you to specify the credentials and configurations required to sign your application, which is a mandatory step for publishing to the Google Play Store and ensuring the integrity of your app [2]. Key properties configurable through a signingConfig include: - storeFile: The location of the keystore file [1][2]. - storePassword: The password for the keystore [1][2]. - keyAlias: The alias of the specific key used for signing [1][2]. - keyPassword: The password for the specific key [1][2]. - storeType: The type of the keystore (e.g., JKS or PKCS12) [1][2]. - Signing Schemes: You can explicitly enable or disable different APK signature schemes, including v1 (JAR signing), v2, v3, and v4 signing [1][3][4]. In your build.gradle (or build.gradle.kts) file, you define these configurations within the android block. Typically, you create a signingConfigs block to define one or more named configurations, which can then be assigned to specific build types or product flavors [2][5]. Example configuration structure: android { signingConfigs { release { storeFile file("my-release-key.jks") storePassword "password" keyAlias "my-alias" keyPassword "password" } } buildTypes { release { signingConfig signingConfigs.release } } }

Citations:


🌐 Web query:

site:developer.android.com/reference/tools/gradle-api/8.13/com/android/build/api/variant/Variant signingConfig setConfig Android Gradle Plugin

💡 Result:

The Android Gradle Plugin uses a signingConfigs block within the android extension to define signing configurations for an application. While the provided documentation for the Variant API does not directly detail a setConfig method for signing, standard configuration is typically handled through the build.gradle (Groovy) or build.gradle.kts (Kotlin) DSL. To configure signing in your build file, define the configurations within the android block: android { signingConfigs { release { storeFile file('my-release-key.jks') storePassword 'password' keyAlias 'my-alias' keyPassword 'password' } } buildTypes { release { signingConfig signingConfigs.release } } } In this configuration, the signingConfigs block creates named signing definitions, which are then assigned to specific build types using the signingConfig property. If you are attempting to programmatically set or modify configurations via the Variant API, note that the API focuses on providing access to variant properties, and direct mutation of signing configurations via a setConfig method is not standard usage for the Android Gradle Plugin.

Citations:


🏁 Script executed:

#!/bin/bash
set -u

printf '%s\n' '--- exact product flavor declarations ---'
sed -n '327,365p' app/build.gradle

printf '%s\n' '--- exact existing signing declarations ---'
sed -n '135,175p' app/build.gradle
sed -n '225,262p' app/build.gradle

printf '%s\n' '--- workflow files mentioning the release workflow ---'
fd -t f -i 'android-tv-release' .github 2>/dev/null | xargs -r -n1 sh -c 'echo "--- $0"; cat -n "$0"'

Repository: celzero/rethink-app

Length of output: 5614


Scope tvRelease signing to TV release variants.

buildTypes.release.signingConfig applies to every release variant, including playFullRelease, fdroidFullRelease, and websiteFullRelease. If TV_RELEASE_KS_* is set, these phone APKs use the TV keystore and cannot update existing phone installations. Use an AGP 8.13-supported per-variant configuration that selects only the tv flavor.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/build.gradle` around lines 413 - 430, Replace the global
buildTypes.release.signingConfig assignment in the TV signing configuration with
AGP 8.13-supported per-variant configuration that applies
signingConfigs.tvRelease only when the release variant uses the tv flavor.
Ensure playFullRelease, fdroidFullRelease, and websiteFullRelease remain
unaffected while TV release variants use the TV keystore.

}

configurations {
Expand Down Expand Up @@ -486,6 +570,105 @@ dependencies {

fullImplementation 'androidx.biometric:biometric:1.1.0'

// ------------------------------------------------------------------
// rethink-tv fork: Android TV flavor (`tv`) dependencies.
// The `tv` flavor's source set inherits `app/src/full/` (configured in
// the `android.sourceSets` block above), so it needs the same UI /
// runtime dependencies that `fullImplementation` provides. This block
// mirrors them as `tvImplementation` declarations. It is intentionally
// append-only and does not modify upstream's existing declarations,
// keeping the upstream-sync diff minimal.
//
// Note: `firestackDependency()` is NOT mirrored here because firestack
// is scoped by `releaseChannel` (play / fdroid / website) above, and
// the `tv` flavor lives in the orthogonal `releaseType` dimension —
// each combined variant (e.g. `fdroidTv`) already picks up firestack
// via `fdroidImplementation`. Mirroring here would double-add it.
// ------------------------------------------------------------------
tvImplementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.1.20'
tvImplementation 'androidx.appcompat:appcompat:1.7.1'
tvImplementation 'androidx.core:core-ktx:1.17.0'
tvImplementation 'androidx.constraintlayout:constraintlayout:2.2.1'
tvImplementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
tvImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2'
tvImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2'
tvImplementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.4'
tvImplementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.9.4'
tvImplementation 'androidx.fragment:fragment-ktx:1.8.9'
tvImplementation 'androidx.viewpager2:viewpager2:1.1.0'
tvImplementation 'com.squareup.okhttp3:okhttp:5.3.2'
tvImplementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:5.3.2'
tvImplementation 'com.squareup.okhttp3:logging-interceptor:5.3.2'
tvImplementation 'com.squareup.retrofit2:retrofit:3.0.0'
tvImplementation 'com.squareup.retrofit2:converter-gson:3.0.0'
tvImplementation('com.github.bumptech.glide:glide:5.0.5') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · high]
Glide version mismatch in the tv mirror. fullImplementation uses Glide 5.0.7 (glide, okhttp3-integration, and kspFull 'com.github.bumptech.glide:compiler:5.0.7' at lines ~502-510), but the new tv block declares glide:5.0.5, okhttp3-integration:5.0.5, and kspTv '...compiler:5.0.5'. Since the tv source set inherits all of src/full/java, the inherited full code is compiled against 5.0.5 in tv variants, which can cause missing-API NoSuchMethodError/binary-incompatibility at runtime or compilation failures. The block is documented as a mirror of fullImplementation, so it should use the exact same versions (5.0.7).

Suggestion:

Suggested change
tvImplementation('com.github.bumptech.glide:glide:5.0.5') {
tvImplementation('com.github.bumptech.glide:glide:5.0.7') {

exclude group: 'glide-parent'
}
tvImplementation('com.github.bumptech.glide:okhttp3-integration:5.0.5') {
exclude group: 'glide-parent'
}
kspTv 'com.github.bumptech.glide:compiler:5.0.5'
tvImplementation 'com.facebook.shimmer:shimmer:0.5.0'
tvImplementation 'com.github.kirich1409:viewbindingpropertydelegate:1.5.9'
tvImplementation 'com.github.kirich1409:viewbindingpropertydelegate-noreflection:1.5.9'
tvImplementation 'androidx.navigation:navigation-fragment-ktx:2.9.6'
tvImplementation 'androidx.navigation:navigation-ui-ktx:2.9.6'
tvImplementation 'androidx.biometric:biometric:1.1.0'
tvImplementation 'com.journeyapps:zxing-android-embedded:4.3.0'
tvImplementation 'com.simplecityapps:recyclerview-fastscroll:2.0.1'
tvImplementation 'nl.dionsegijn:konfetti-xml:2.0.5'

// ------------------------------------------------------------------
// rethink-tv fork: Compose-for-TV stack for the `tv` flavor.
// The Compose Compiler plugin (applied project-wide so a single
// `id 'org.jetbrains.kotlin.plugin.compose'` covers every variant)
// performs a classpath check at compile time on every applied variant,
// including phone variants that contain zero @Composable code. To
// satisfy that check without pulling the full UI stack into phone
// builds, we expose only the BOM and the runtime artifact to all
// variants; the rest (ui, foundation, material3, tv-material,
// navigation-compose, etc.) stays scoped to `tvImplementation`.
// Cost to phone variants: ~250 KB of unused Compose runtime classes,
// which never get loaded because phone code never invokes @Composable.
// ------------------------------------------------------------------
implementation platform('androidx.compose:compose-bom:2024.12.01')
implementation 'androidx.compose.runtime:runtime'

tvImplementation platform('androidx.compose:compose-bom:2024.12.01')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[maintainability · low]
The Compose BOM is declared twice for tv variants: implementation platform('androidx.compose:compose-bom:2024.12.01') is project-wide (it already applies to the tv flavor via the shared configuration), and this tvImplementation platform(...) duplicates it. Since both use the same version the duplication is harmless, but it is redundant and can confuse which BOM version governs tv dependencies — the extra tvImplementation platform can be removed.

tvImplementation 'androidx.compose.ui:ui'
tvImplementation 'androidx.compose.ui:ui-tooling-preview'
tvImplementation 'androidx.compose.foundation:foundation'
tvImplementation 'androidx.compose.runtime:runtime-livedata'
tvImplementation 'androidx.compose.material3:material3'
// TV-styled Material 3 components (focusable Surface / Button /
// Card with the focus borders Android TV users expect).
tvImplementation 'androidx.tv:tv-material:1.0.0'
tvImplementation 'androidx.activity:activity-compose:1.9.3'
tvImplementation 'androidx.lifecycle:lifecycle-runtime-compose:2.8.7'
tvImplementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7'
// Koin's Compose extensions (`koinInject` / `koinViewModel`) so the TV
// UI can pull `PersistentState` and other injected singletons without
// hand-wiring KoinComponent into every Composable.
tvImplementation 'io.insert-koin:koin-androidx-compose:4.1.1'
// Compose Navigation drives the TV NavHost. Used in tandem with the
// tv-material NavigationDrawer to host every TV destination
// (Home / DNS / Firewall / Apps / Proxy / Logs / Stats / Settings)
// inside a single Activity, avoiding new <activity> declarations in
// the TV-flavor manifest.
tvImplementation 'androidx.navigation:navigation-compose:2.8.5'
// Material Icons Extended supplies the leanback iconography
// (Shield, Dns, Apps, Hub, Article, Insights, Settings, Home) that
// the TV nav rail needs. Scoped to `tvImplementation` so phone
// variants never pull the ~3 MB icon font.
tvImplementation 'androidx.compose.material:material-icons-extended'
// Paging-Compose provides `collectAsLazyPagingItems()` and the
// `items()` overload that consume PagingSource directly — the Logs
// destination feeds it ConnectionTrackerDAO.getConnectionTrackerByName()
// verbatim, so we don't have to recreate the upstream paging
// pipeline. Scoped to tvImplementation; phone variants use their
// existing Fragment + Paging-Runtime adapter.
tvImplementation 'androidx.paging:paging-compose:3.3.5'

playImplementation 'com.google.android.play:app-update:2.1.0'
playImplementation 'com.google.android.play:app-update-ktx:2.1.0'

Expand Down
27 changes: 21 additions & 6 deletions app/src/main/java/com/celzero/bravedns/database/AppDatabase.kt
Original file line number Diff line number Diff line change
Expand Up @@ -98,26 +98,42 @@ abstract class AppDatabase : RoomDatabase() {
}

fun buildDatabase(context: Context): AppDatabase {
val appContext = context.applicationContext
// Self-heal: if a corrupt/truncated bravedns.db is present on disk (e.g. a
// 0-byte file left behind by a premature ATTACH in LogDatabase.populateDatabase
// after the user clears app storage via Android settings), delete it so that
// Room's createFromAsset() re-copies the pre-packaged asset and the seed data
// (default DoH/DNSCrypt/RDNS/DoT/ODoH rows) is restored. Without this, Room sees
// that the file already exists and skips the asset copy, failing with:
// "Bad database header, unable to read 4 bytes at offset 60" (user_version).
val dbFile = context.applicationContext.getDatabasePath(DATABASE_NAME)
val dbFile = appContext.getDatabasePath(DATABASE_NAME)
if (dbFile.exists() && !isValidSQLiteFile(dbFile)) {
Logger.i(
LOG_TAG_APP_DB,
"Corrupt DB file detected (${dbFile.length()} bytes); deleting to allow asset re-copy"
)
dbFile.delete()
// remove sidecar files so a stale wal/shm cannot resurrect broken state
context.applicationContext.getDatabasePath("$DATABASE_NAME-wal").delete()
context.applicationContext.getDatabasePath("$DATABASE_NAME-shm").delete()
appContext.getDatabasePath("$DATABASE_NAME-wal").delete()
appContext.getDatabasePath("$DATABASE_NAME-shm").delete()
Comment on lines +109 to +118

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

file="app/src/main/java/com/celzero/bravedns/database/AppDatabase.kt"
printf '%s\n' '--- target file ---'
sed -n '1,220p' "$file"

printf '%s\n' '--- related database symbols and journal handling ---'
rg -n -C 3 'buildDatabase|isValidSQLiteFile|JournalMode|DATABASE_NAME|IllegalStateException|sqlite|journal|wal|shm' app/src/main/java app/src/test test 2>/dev/null || true

printf '%s\n' '--- database-related files ---'
git ls-files | rg '(^|/)(AppDatabase|.*Database.*|.*Migration.*|.*Test.*Database.*)|sqlite|room' || true

Repository: celzero/rethink-app

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -eu

file="app/src/main/java/com/celzero/bravedns/database/AppDatabase.kt"
sed -n '80,155p' "$file"

printf '%s\n' '--- imports and declarations ---'
sed -n '1,85p' "$file"

printf '%s\n' '--- exact related references ---'
rg -n -C 5 'buildDatabase|isValidSQLiteFile|JournalMode|DATABASE_NAME|Corrupt DB|allow asset re-copy|getDatabasePath' "$file" app/src/main 2>/dev/null | head -n 300

printf '%s\n' '--- dependency declarations mentioning Room or SQLite ---'
rg -n -C 2 'room|sqlite' --glob 'build.gradle*' --glob '*.gradle' --glob '*.gradle.kts' --glob 'gradle/libs.versions.toml' . 2>/dev/null | head -n 200

Repository: celzero/rethink-app

Length of output: 37585


🌐 Web query:

Android SQLiteDatabase JournalMode AUTOMATIC low RAM TRUNCATE rollback journal hot journal recovery delete database sidecar files Room createFromAsset

💡 Result:

In Android, SQLite database operations and Room database initialization involve specific handling of journaling, crash recovery, and file management. Journal Mode AUTOMATIC When using Room with JournalMode.AUTOMATIC, the library intelligently selects a journal mode based on the device configuration [1][2]. On low-RAM devices or API levels below 16, it defaults to TRUNCATE mode [1][2]. On other devices, it utilizes WRITE_AHEAD_LOGGING (WAL) [1][2]. TRUNCATE mode commits transactions by truncating the rollback journal to zero-length rather than deleting it, which can be faster on certain file systems [3]. Hot Journal Recovery and Sidecar Files SQLite ensures atomic commits through the use of auxiliary "sidecar" files, specifically the rollback journal (-journal) or write-ahead log (-wal) [4][5]. If an application or device crashes during a transaction, these files may be left on the disk, becoming "hot journals" or "hot WAL files" [6][7][4]. When an SQLite database is subsequently opened, it detects these files and automatically performs a recovery process by replaying them to restore the database to a consistent state [6][7]. It is critical that these sidecar files remain in the same directory as the database file to enable this automatic recovery; moving, renaming, or deleting them manually can lead to database corruption [7][5]. Room createFromAsset When using Room's createFromAsset(String) method, Room does not open the pre-packaged database file directly from the assets folder [8][9]. Instead, it copies the file into the application's internal database directory and then opens the copy [8][9]. This ensures the database is in a writable, app-managed location [10]. When using this feature, it is recommended to ensure the schema of the pre-packaged binary file matches the Room entity definitions, which can be verified by exporting the schema during development [11][12].

Citations:


🌐 Web query:

SQLite hot journal recovery conditions rollback journal stale journal main database replacement delete database file

💡 Result:

In SQLite, a hot journal is a rollback journal file (typically with a -journal suffix) left on disk because a process crashed or lost power while in the middle of a transaction [1][2][3]. Recovery Conditions When any new database connection attempts the first transaction on a database file, SQLite checks for the presence of a corresponding hot journal [1][4][5]. If found, the database is considered in an inconsistent state, and automatic recovery is triggered to restore atomicity [1][2][6]. Recovery Process 1. Exclusive Lock: The recovery process acquires an exclusive lock on the database file to prevent concurrent access by other processes [1][4]. 2. Playback: SQLite reads the original, unmodified page contents from the hot journal and writes them back into the main database file [1][7][8]. 3. Truncation: If the aborted transaction caused the database file to grow, SQLite uses information in the journal to truncate the file back to its original size [1][9]. 4. Cleanup: After all data is successfully restored and flushed to disk, the hot journal file is deleted (or its header is zeroed in PERSIST mode) [1][2][7]. Stale Journals A stale journal is a file that is no longer needed for recovery [9]. While the term is most often associated with "super-journals" used in multi-database transactions, a standard rollback journal that persists after a successful, non-crashing transaction (e.g., in PERSIST mode) is functionally obsolete once the transaction is complete [2][9]. Unlike hot journals, stale journals do not need to be processed to maintain database integrity; they may be safely deleted to free disk space [9]. Main Database Replacement & Deletion You should never manually replace or delete a main database file or its associated journal/WAL files while any process is accessing the database [3]. If you replace the main database file, you must also ensure any associated hot journals are properly handled or removed, as SQLite requires the journal to exist to perform recovery [3]. If a hot journal is deleted, moved, or renamed while the database is in an inconsistent state, automatic recovery will fail, and the database file will likely become corrupted [2][3]. The database state consists of both the main file and its auxiliary journals; they must always be kept together [3].

Citations:


🌐 Web query:

Android Context deleteDatabase implementation -journal -wal -shm SQLiteDatabase

💡 Result:

In Android, deleting an SQLite database is handled by the static method SQLiteDatabase.deleteDatabase(File file) [1][2]. When called, this method does not simply remove the primary database file; it comprehensively cleans up the database by deleting the primary file along with several associated auxiliary and journal files that may have been created by the SQLite engine [1][3]. Implementation Details The implementation of SQLiteDatabase.deleteDatabase(File file) explicitly deletes the following files associated with the provided database file path [1][3]: - Primary database file: The file itself [1][3]. - Journal files: Files with the suffixes -journal, -shm, and -wal [1][3]. - Master journal files: Any files in the same directory that start with the database file name followed by the prefix -mj [1][3]. - WIPE_CHECK_FILE_SUFFIX: An additional auxiliary file, if present, is also removed [1][3]. Usage in Context Developers typically interact with this functionality through the Context class. The Context.deleteDatabase(String name) method (implemented in ContextImpl) simplifies this process by [4][5]: 1. Resolving the database name to its absolute file path using getDatabasePath(name) [4][5]. 2. Passing the resulting File object to the static SQLiteDatabase.deleteDatabase(File) method [4][6]. The Context.deleteDatabase(String name) method returns a boolean indicating whether the deletion was successful [4][6]. If you are debugging and need to manually clear a database, you can also use the Android device's settings to clear the application's storage, or use adb shell commands (such as rm) to remove the files directly from the application's private data directory [7][8].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
import os
import shutil
import sqlite3
import tempfile

root = tempfile.mkdtemp(prefix="sqlite-journal-probe-")
db = os.path.join(root, "bravedns.db")
asset = os.path.join(root, "asset.db")

# Create a database and leave an in-progress rollback transaction on disk.
conn = sqlite3.connect(db)
conn.execute("PRAGMA journal_mode=TRUNCATE")
conn.execute("CREATE TABLE state (value TEXT)")
conn.execute("INSERT INTO state VALUES ('original')")
conn.commit()
conn.close()

pid = os.fork()
if pid == 0:
    child = sqlite3.connect(db)
    child.execute("BEGIN IMMEDIATE")
    child.execute("UPDATE state SET value = 'uncommitted-old-value'")
    child.execute("PRAGMA wal_checkpoint(TRUNCATE)")
    os._exit(0)

os.waitpid(pid, 0)
journal = db + "-journal"
print("rollback_journal_exists_after_crash:", os.path.exists(journal))
print("rollback_journal_size_after_crash:", os.path.getsize(journal) if os.path.exists(journal) else 0)

# Create the replacement asset, then replace only the primary file.
asset_conn = sqlite3.connect(asset)
asset_conn.execute("CREATE TABLE state (value TEXT)")
asset_conn.execute("INSERT INTO state VALUES ('replacement-asset')")
asset_conn.commit()
asset_conn.close()
shutil.copyfile(asset, db)

# SQLite opens the replacement while the old rollback journal remains.
replacement = sqlite3.connect(db)
value = replacement.execute("SELECT value FROM state").fetchone()[0]
replacement.close()
print("value_after_open_with_old_journal:", value)
print("journal_exists_after_open:", os.path.exists(journal))

shutil.rmtree(root)
PY

printf '%s\n' '--- Android deletion implementation references ---'
curl -fsSL \
  'https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/database/sqlite/SQLiteDatabase.java?format=TEXT' |
  base64 -d |
  rg -n -A 45 -B 8 'deleteDatabase\(File|WAL_FILE_SUFFIX|JOURNAL_FILE_SUFFIX|SHM_FILE_SUFFIX'

Repository: celzero/rethink-app

Length of output: 499


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
import os
import shutil
import sqlite3
import subprocess
import sys
import tempfile

root = tempfile.mkdtemp(prefix="sqlite-journal-probe-")
db = os.path.join(root, "bravedns.db")
asset = os.path.join(root, "asset.db")
child_script = os.path.join(root, "crash.py")

conn = sqlite3.connect(db)
conn.execute("PRAGMA journal_mode=TRUNCATE")
conn.execute("CREATE TABLE state (value TEXT)")
conn.execute("INSERT INTO state VALUES ('original')")
conn.commit()
conn.close()

with open(child_script, "w") as f:
    f.write(
        "import os, sqlite3\n"
        f"c = sqlite3.connect({db!r})\n"
        "c.execute('BEGIN IMMEDIATE')\n"
        "c.execute(\"UPDATE state SET value = 'uncommitted-old-value'\")\n"
        "os._exit(0)\n"
    )

subprocess.run([sys.executable, child_script], check=True)
journal = db + "-journal"
print("rollback_journal_exists_after_crash:", os.path.exists(journal))
print("rollback_journal_size_after_crash:", os.path.getsize(journal) if os.path.exists(journal) else 0)

asset_conn = sqlite3.connect(asset)
asset_conn.execute("PRAGMA journal_mode=TRUNCATE")
asset_conn.execute("CREATE TABLE state (value TEXT)")
asset_conn.execute("INSERT INTO state VALUES ('replacement-asset')")
asset_conn.commit()
asset_conn.close()
shutil.copyfile(asset, db)

replacement = sqlite3.connect(db)
value = replacement.execute("SELECT value FROM state").fetchone()[0]
replacement.close()
print("value_after_open_with_old_journal:", value)
print("journal_exists_after_open:", os.path.exists(journal))
print("probe_directory:", root)

shutil.rmtree(root)
PY

printf '%s\n' '--- Android deletion implementation ---'
tmp=$(mktemp)
trap 'rm -f "$tmp" "$tmp.decoded"' EXIT
curl -fsSL \
  'https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/database/sqlite/SQLiteDatabase.java?format=TEXT' \
  -o "$tmp"
head -c 120 "$tmp"
printf '\n'
if base64 -d "$tmp" > "$tmp.decoded" 2>/dev/null; then
  rg -n -A 45 -B 8 'deleteDatabase\(File|WAL_FILE_SUFFIX|JOURNAL_FILE_SUFFIX|SHM_FILE_SUFFIX' "$tmp.decoded" || true
else
  cat "$tmp"
fi

Repository: celzero/rethink-app

Length of output: 537


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
import os
import shutil
import sqlite3
import subprocess
import sys
import tempfile

for mode in ("delete", "truncate"):
    root = tempfile.mkdtemp(prefix=f"sqlite-journal-probe-{mode}-")
    db = os.path.join(root, "bravedns.db")
    asset = os.path.join(root, "asset.db")
    child_script = os.path.join(root, "crash.py")

    conn = sqlite3.connect(db)
    conn.execute(f"PRAGMA journal_mode={mode}")
    conn.execute("CREATE TABLE state (value TEXT)")
    conn.execute("INSERT INTO state VALUES ('original')")
    conn.commit()
    conn.close()

    with open(child_script, "w") as f:
        f.write(
            "import os, sqlite3\n"
            f"c = sqlite3.connect(%r)\n"
            "c.execute('BEGIN IMMEDIATE')\n"
            "c.execute(\"UPDATE state SET value = 'uncommitted-old-value'\")\n"
            "os._exit(0)\n" % db
        )

    subprocess.run([sys.executable, child_script], check=True)
    journal = db + "-journal"
    with open(journal, "rb") as f:
        header = f.read(32)

    asset_conn = sqlite3.connect(asset)
    asset_conn.execute(f"PRAGMA journal_mode={mode}")
    asset_conn.execute("CREATE TABLE state (value TEXT)")
    asset_conn.execute("INSERT INTO state VALUES ('replacement-asset')")
    asset_conn.commit()
    asset_conn.close()
    shutil.copyfile(asset, db)

    replacement = sqlite3.connect(db)
    value = replacement.execute("SELECT value FROM state").fetchone()[0]
    replacement.close()

    print(mode, {
        "journal_exists": os.path.exists(journal),
        "journal_size": os.path.getsize(journal) if os.path.exists(journal) else 0,
        "journal_header_hex": header.hex(),
        "value_after_replacement_open": value,
        "journal_exists_after_open": os.path.exists(journal),
    })
    shutil.rmtree(root)
PY

printf '%s\n' '--- Android SQLiteDatabase.deleteDatabase source ---'
curl -fsSL \
  'https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/database/sqlite/SQLiteDatabase.java?format=TEXT' |
  base64 -d |
  python3 -c '
import sys
lines = sys.stdin.read().splitlines()
for i, line in enumerate(lines):
    if "deleteDatabase" in line or "JOURNAL_FILE_SUFFIX" in line or "WAL_FILE_SUFFIX" in line or "SHM_FILE_SUFFIX" in line:
        lo, hi = max(0, i - 5), min(len(lines), i + 35)
        print("\n".join(f"{n+1}: {lines[n]}" for n in range(lo, hi)))
'

Repository: celzero/rethink-app

Length of output: 5795


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
import os
import signal
import sqlite3
import subprocess
import sys
import tempfile
import time

root = tempfile.mkdtemp(prefix="sqlite-hot-journal-")
db = os.path.join(root, "bravedns.db")
child = os.path.join(root, "writer.py")

c = sqlite3.connect(db)
c.execute("PRAGMA journal_mode=DELETE")
c.execute("PRAGMA synchronous=FULL")
c.execute("CREATE TABLE state (value TEXT)")
c.execute("INSERT INTO state VALUES ('original')")
c.commit()
c.close()

with open(child, "w") as f:
    f.write(
        "import sqlite3, time\n"
        f"c = sqlite3.connect({db!r})\n"
        "c.execute('PRAGMA synchronous=FULL')\n"
        "c.execute('BEGIN IMMEDIATE')\n"
        "c.execute(\"UPDATE state SET value = 'uncommitted-old-value'\")\n"
        "time.sleep(30)\n"
    )

p = subprocess.Popen([sys.executable, child])
journal = db + "-journal"
try:
    for _ in range(100):
        if os.path.exists(journal) and os.path.getsize(journal) >= 512:
            break
        time.sleep(0.05)

    with open(journal, "rb") as f:
        header = f.read(16)
    print("journal_exists_before_kill:", os.path.exists(journal))
    print("journal_size_before_kill:", os.path.getsize(journal))
    print("journal_header_before_kill:", header.hex())
finally:
    os.kill(p.pid, signal.SIGKILL)
    p.wait()

print("journal_exists_after_kill:", os.path.exists(journal))
with open(journal, "rb") as f:
    print("journal_header_after_kill:", f.read(16).hex())
print("probe_directory:", root)
PY

Repository: celzero/rethink-app

Length of output: 424


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
import os
import signal
import sqlite3
import subprocess
import sys
import tempfile
import time

root = tempfile.mkdtemp(prefix="sqlite-journal-layout-")
db = os.path.join(root, "bravedns.db")
child = os.path.join(root, "writer.py")

c = sqlite3.connect(db)
c.execute("PRAGMA journal_mode=DELETE")
c.execute("PRAGMA synchronous=FULL")
c.execute("PRAGMA page_size=4096")
c.execute("CREATE TABLE state (value TEXT)")
c.execute("INSERT INTO state VALUES ('original')")
c.commit()
c.close()

with open(child, "w") as f:
    f.write(
        "import sqlite3, time\n"
        f"c = sqlite3.connect({db!r})\n"
        "c.execute('PRAGMA synchronous=FULL')\n"
        "c.execute('BEGIN IMMEDIATE')\n"
        "c.execute(\"UPDATE state SET value = 'uncommitted-old-value'\")\n"
        "time.sleep(30)\n"
    )

p = subprocess.Popen([sys.executable, child])
journal = db + "-journal"
try:
    for _ in range(100):
        if os.path.exists(journal) and os.path.getsize(journal) >= 512:
            break
        time.sleep(0.05)

    data = open(journal, "rb").read()
    print("journal_size:", len(data))
    print("nonzero_ranges:")
    start = None
    for i, value in enumerate(data + b"\0"):
        if value and start is None:
            start = i
        elif not value and start is not None:
            print(start, i, data[start:i].hex()[:160])
            start = None
finally:
    os.kill(p.pid, signal.SIGKILL)
    p.wait()
    import shutil
    shutil.rmtree(root)
PY

Repository: celzero/rethink-app

Length of output: 318


Delete the rollback journal during recovery.

When dbFile fails validation, delete "$DATABASE_NAME-journal" with the other sidecars. JournalMode.AUTOMATIC can select TRUNCATE, which uses this rollback journal. A hot journal can replay pages into the replacement asset. Check each existing file deletion and abort recovery when a required deletion fails.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/com/celzero/bravedns/database/AppDatabase.kt` around lines
109 - 118, Update the invalid-database recovery flow around dbFile.delete() to
also delete the "$DATABASE_NAME-journal" sidecar. Check the result of deleting
dbFile, the journal, wal, and shm files, and abort recovery when any required
deletion fails, preserving the existing recovery behavior only when all
deletions succeed.

}
return Room.databaseBuilder(
context.applicationContext,

return try {
newBuilder(appContext).also { it.openHelper.writableDatabase }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[performance · medium]
This forces the database to be opened synchronously during buildDatabase(): the asset copy, all pending migrations (1→31), and schema validation all run on the calling thread. Since this is registered as a lazy Koin single in DatabaseModule.kt, the first get<AppDatabase>() — typically on the main thread during app startup (Koin is started in Application.onCreate) — now performs blocking disk I/O that was previously deferred to the first DAO query. This can cause startup jank/ANR (and StrictMode violations in debug) on slow devices, especially with a large DB to migrate. Consider performing the self-heal verification open on a background dispatcher and only then returning the validated instance.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[performance · medium]
This forces the database to be opened synchronously during buildDatabase(): the asset copy, all pending migrations (1→31), and schema validation all run on the calling thread. Since this is registered as a lazy Koin single in DatabaseModule.kt, the first get<AppDatabase>() — typically on the main thread during app startup (Koin is started in Application.onCreate) — now performs blocking disk I/O that was previously deferred to the first DAO query. This can cause startup jank/ANR (and StrictMode violations in debug) on slow devices, especially with a large DB to migrate. Consider performing the self-heal verification open on a background dispatcher and only then returning the validated instance.

} catch (e: IllegalStateException) {
val message = e.message.orEmpty()
if ("Room cannot verify" !in message && "data integrity" !in message) {
throw e
}
Logger.w(LOG_TAG_APP_DB, "Schema mismatch; recreating database: $message")
appContext.deleteDatabase(DATABASE_NAME)
Comment on lines +128 to +129

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · high]
This catch block deletes the entire bravedns.db whenever an IllegalStateException message contains "Room cannot verify" / "data integrity", silently discarding all user data (firewall rules, DNS endpoints, subscriptions, etc.) in production. If the mismatch stems from a genuine regression — e.g., a schema/entity change without a version bump, or an app downgrade — the app will silently wipe user data on every launch instead of failing loudly, masking the bug. Detection by substring-matching Room's internal exception message is also brittle across Room versions. Consider narrowing the trigger (e.g., verify the file fails schema validation independently), and log/report the event before deletion so silent data loss can be diagnosed.

Comment on lines +128 to +129

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · high]
This catch block deletes the entire bravedns.db whenever an IllegalStateException message contains "Room cannot verify" / "data integrity", silently discarding all user data (firewall rules, DNS endpoints, subscriptions, etc.) in production. If the mismatch stems from a genuine regression — e.g., a schema/entity change without a version bump, or an app downgrade — the app will silently wipe user data on every launch instead of failing loudly, masking the bug. Detection by substring-matching Room's internal exception message is also brittle across Room versions. Consider narrowing the trigger (e.g., verify the file fails schema validation independently), and log/report the event before deletion so silent data loss can be diagnosed.

newBuilder(appContext)
}
Comment on lines +121 to +131

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e
file="app/src/main/java/com/celzero/bravedns/database/AppDatabase.kt"
printf '%s\n' '--- file outline ---'
ast-grep outline "$file" --lang kotlin || true
printf '%s\n' '--- target lines ---'
sed -n '1,180p' "$file"
printf '%s\n' '--- related database and corruption checks ---'
rg -n -i "isValidSQLiteFile|SQLiteException|SQLiteDatabaseCorruptException|Room cannot verify|data integrity|deleteDatabase|writableDatabase|Schema mismatch" app/src test* 2>/dev/null || true
printf '%s\n' '--- candidate tests ---'
git ls-files | rg -i '(AppDatabase|database|sqlite|room).*(test|spec)|((test|spec).*(AppDatabase|database|sqlite|room))' || true

Repository: celzero/rethink-app

Length of output: 12862


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- LogDatabase exception handling ---'
sed -n '120,180p' app/src/main/java/com/celzero/bravedns/database/LogDatabase.kt
printf '%s\n' '--- Android SQLite imports and exception usage ---'
rg -n -C 4 "android\.database\.sqlite\.(SQLiteException|SQLiteDatabaseCorruptException)|catch \(.*SQLite" app/src/main/java app/src/test app/src/androidTest 2>/dev/null || true
printf '%s\n' '--- dependency declarations ---'
rg -n -i "room|sqlite|androidx\.sqlite|compileSdk|minSdk|targetSdk" --glob '!*build*' --glob '*.gradle' --glob '*.gradle.kts' .

Repository: celzero/rethink-app

Length of output: 9874


🌐 Web query:

Android SQLiteDatabaseCorruptException hierarchy open malformed database header SQLiteException documentation

💡 Result:

The SQLiteDatabaseCorruptException is a specialized exception in the Android framework that indicates the underlying SQLite database file is corrupt [1][2][3]. Class Hierarchy The class hierarchy for SQLiteDatabaseCorruptException is as follows [2][4][3][5]: java.lang.Object ↳ java.lang.Throwable ↳ java.lang.Exception ↳ java.lang.RuntimeException ↳ android.database.SQLException ↳ android.database.sqlite.SQLiteException ↳ android.database.sqlite.SQLiteDatabaseCorruptException As shown above, SQLiteDatabaseCorruptException extends SQLiteException, which is a RuntimeException [3][5]. It is one of many specific subclasses of SQLiteException that provides granular error reporting for database operations [3][5]. Handling Corruption When SQLite reports database corruption, the Android framework allows developers to define custom handling logic via the DatabaseErrorHandler interface [6][7]. An implementation of this interface can be provided when opening a database; the onCorruption method is then invoked by the system if corruption is detected [6][7]. Regarding "malformed database header" While SQLiteDatabaseCorruptException is the general exception thrown for corruption, the specific error message "malformed database header" is a standard diagnostic string returned by the SQLite engine itself when it cannot validate the file's header, indicating the file is not a valid SQLite database or is severely damaged. This string is typically passed as the detail message within the SQLiteDatabaseCorruptException object [1][8].

Citations:


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- AppDatabase callers ---'
rg -n -C 5 "buildDatabase\(" app/src/main app/src/test app/src/androidTest 2>/dev/null || true
printf '%s\n' '--- database test infrastructure and test rules ---'
rg -n -i -C 3 "Room\.inMemoryDatabaseBuilder|Room\.databaseBuilder|AppDatabase|SupportSQLiteOpenHelper|SQLiteDatabaseCorruptException|createFromAsset" app/src/test app/src/androidTest 2>/dev/null || true
printf '%s\n' '--- relevant framework source references ---'
for url in \
  "https://android.googlesource.com/platform/frameworks/base/+/refs/heads/master/core/java/android/database/sqlite/SQLiteDatabase.java?format=TEXT" \
  "https://android.googlesource.com/platform/frameworks/base/+/refs/heads/master/core/java/android/database/sqlite/SQLiteOpenHelper.java?format=TEXT" \
  "https://android.googlesource.com/platform/frameworks/base/+/refs/heads/master/core/java/android/database/sqlite/SQLiteConnection.java?format=TEXT"; do
  echo "--- $url ---"
  curl -L --fail --silent "$url" | base64 -d 2>/dev/null | rg -n -C 5 "SQLiteDatabaseCorruptException|onCorruption|open failed|malformed|SQLiteException" || true
done

Repository: celzero/rethink-app

Length of output: 34676


🌐 Web query:

Android SQLiteConnection open malformed database header SQLiteDatabaseCorruptException SQLiteOpenHelper getWritableDatabase source

💡 Result:

The SQLiteDatabaseCorruptException with the message database disk image is malformed indicates that the SQLite database engine has detected inconsistency in the database file, often triggered by a malformed header or internal page corruption [1][2][3]. When this occurs during an attempt to open or use a database in Android, it typically triggers the DatabaseErrorHandler [4][5]. Default Behavior and Handling: By default, Android uses the DefaultDatabaseErrorHandler [6]. When it detects corruption, this handler attempts to close the connection and delete the affected database file (and its associated journal/WAL/SHM files) [7][5][6]. This ensures that subsequent attempts to open the database start from a clean state, though it results in data loss for that database [7][5]. Common Causes: 1. Improper File Management: When using a pre-populated database (e.g., from assets), failing to delete old WAL (-wal) and SHM (-shm) files before copying a new version often leads to corruption errors, as SQLite attempts to associate these orphaned files with the new database [2]. Using context.deleteDatabase("name") is the recommended way to ensure all associated files are removed [2]. 2. Improper File Copying: Corruptions frequently occur during the initial copy of a database file if the I/O stream is not managed correctly (e.g., incomplete writes) [8][3]. 3. Environmental Factors: External factors such as unexpected app termination, device power loss, or bugs in the underlying SQLite engine can lead to physical file corruption [7][9]. 4. Multi-threading/Connection Issues: Accessing the same database file from multiple connections or threads without proper synchronization can cause state inconsistency [7][9]. Preventative and Recovery Measures: - Use Correct Deletion: Always use Context.deleteDatabase(String) instead of manually deleting just the main database file to ensure all journal/WAL files are purged [2]. - Implement Custom Error Handling: You can override the default behavior by implementing the DatabaseErrorHandler interface and passing it to the SQLiteOpenHelper constructor [4][5]. This allows you to perform custom actions, such as logging the error, attempting a repair (e.g., using sqlite3 command-line tools if accessible), or restoring from a backup, instead of simply allowing the file to be deleted [5][10][6]. - Maintain Backups: Because SQLite corruption can be irreversible, maintaining periodic backups of critical data is a standard best practice [9].

Citations:


🌐 Web query:

AndroidX Room 2.8.1 FrameworkSQLiteOpenHelper writableDatabase SQLiteDatabaseCorruptException wrapping source

💡 Result:

In AndroidX Room, the call path implied by your query is: RoomDatabase -> SupportSQLiteOpenHelper -> (default on Android) FrameworkSQLiteOpenHelper -> FrameworkSQLiteOpenHelper.OpenHelper extends SQLiteOpenHelper -> SQLiteOpenHelper.getWritableDatabase -> SQLiteDatabaseCorruptException can be thrown; AndroidX Room wraps it through its SQLite wrapper and corruption callback logic. 1) Where FrameworkSQLiteOpenHelper delegates to Android’s SQLiteOpenHelper (i.e., where writableDatabase is obtained) - FrameworkSQLiteOpenHelper exposes a writable database by delegating to its internal delegate/open helper. In the Room support code, getWritableDatabase returns mDelegate.getWritableSupportDatabase. [1][2] - The underlying OpenHelper class (FrameworkSQLiteOpenHelper.OpenHelper) extends android.database.sqlite.SQLiteOpenHelper and overrides getWritableSupportDatabase to call super.getWritableDatabase. [1] 2) How corruption is handled inside FrameworkSQLiteOpenHelper (what “wrapping source” looks like) - FrameworkSQLiteOpenHelper.OpenHelper constructs SQLiteOpenHelper with a DatabaseErrorHandler implementation. That handler overrides onCorruption(SQLiteDatabase dbObj) and forwards corruption to Room’s callback via callback.onCorruption(getWrappedDb(dbRef, dbObj)). [1] - So when Android/SQLite reports corruption, Android’s DatabaseErrorHandler flow supplies the SQLiteDatabase instance (dbObj) to FrameworkSQLiteOpenHelper, which then wraps it as a FrameworkSQLiteDatabase (via getWrappedDb) before invoking Room’s SupportSQLiteOpenHelper.Callback.onCorruption. [1] 3) What a “SQLiteDatabaseCorruptException” means in Android - android.database.sqlite.SQLiteDatabaseCorruptException is explicitly documented as indicating that the SQLite database file is corrupt. [3] 4) What Android does by default when corruption is detected (corresponds to the exception being caught/handled at open time) - Android’s DefaultDatabaseErrorHandler.onCorruption(...) logs the corruption and wipes/deletes the detected database files (and attached databases if applicable), which is part of the mechanism that prevents repeated open attempts from using a corrupted file. [4] - FrameworkSQLiteOpenHelper, however, supplies its own DatabaseErrorHandler to route corruption into Room’s onCorruption callback instead of relying solely on the default handler’s deletion behavior. [1][4] Practical implication / typical outcome in Room - If corruption is detected while opening for write (writableDatabase), SQLiteOpenHelper will invoke the DatabaseErrorHandler.onCorruption flow. FrameworkSQLiteOpenHelper routes that to SupportSQLiteOpenHelper.Callback.onCorruption with a wrapped SupportSQLiteDatabase created from the underlying SQLiteDatabase object. [1] If you want, paste the exact stack trace lines around FrameworkSQLiteOpenHelper and the first occurrence of SQLiteDatabaseCorruptException, and I can map each frame precisely to the relevant Room/FrameworkSQLiteOpenHelper code paths (including whether it’s thrown from open-time or from a later Room operation).

Citations:


Handle SQLiteDatabaseCorruptException during database opening.

A malformed database can pass isValidSQLiteFile and fail at writableDatabase. Catch SQLiteDatabaseCorruptException separately, delete the database with appContext.deleteDatabase(DATABASE_NAME), and rebuild from the asset. Add an Android test for this path. Do not catch broad SQLiteException, I/O, or locking errors.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/com/celzero/bravedns/database/AppDatabase.kt` around lines
121 - 131, Add a dedicated SQLiteDatabaseCorruptException catch around the
writableDatabase access in the newBuilder/opening flow, delete DATABASE_NAME via
appContext.deleteDatabase, and rebuild from the asset using the existing builder
path. Keep non-corruption SQLite, I/O, and locking exceptions propagating, and
add an Android test covering recovery from corruption.

}

private fun newBuilder(context: Context): AppDatabase =
Room.databaseBuilder(
context,
AppDatabase::class.java,
DATABASE_NAME
)
Expand Down Expand Up @@ -155,7 +171,6 @@ abstract class AppDatabase : RoomDatabase() {
.addMigrations(MIGRATION_29_30)
.addMigrations(MIGRATION_30_31)
.build()
}

private val roomCallback: Callback =
object : Callback() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,19 +81,35 @@ interface ConnectionTrackerDAO {
@Query("select * from ConnectionTracker order by id desc")
fun getConnectionTrackerByName(): PagingSource<Int, ConnectionTracker>

@Query("select * from ConnectionTracker where uid = :uid order by id desc")
fun getConnectionTrackerByName(uid: Int): PagingSource<Int, ConnectionTracker>
Comment on lines +84 to +85

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[performance · medium]
The new uid-filtered queries (this one plus the query+uid variants for both getConnectionTrackerByName and getBlockedConnections) run a full-table scan on the ConnectionTracker log table, because the uid column has no index in the entity definition (only ipAddress, appName, dnsQuery, blockedByRule, isBlocked+timeStamp, connId and proxyDetails are indexed). Since these are PagingSource queries executed repeatedly while scrolling, on a table that can hold tens of thousands of log rows this will degrade paging performance. Consider adding Index(value = ["uid"]) (or better Index(value = ["uid", "id"]) to satisfy the order by id desc) to ConnectionTracker together with the required Room migration.


@Query(
"select * from ConnectionTracker where (appName like :query or ipAddress like :query or dnsQuery like :query or flag like :query or proxyDetails like :query or connId like :query) order by id desc"
)
fun getConnectionTrackerByName(query: String): PagingSource<Int, ConnectionTracker>

@Query(
"select * from ConnectionTracker where uid = :uid and (appName like :query or ipAddress like :query or dnsQuery like :query or flag like :query or proxyDetails like :query or connId like :query) order by id desc"
)
fun getConnectionTrackerByName(query: String, uid: Int): PagingSource<Int, ConnectionTracker>

@Query("select * from ConnectionTracker where isBlocked = 1 order by id desc")
fun getBlockedConnections(): PagingSource<Int, ConnectionTracker>

@Query("select * from ConnectionTracker where uid = :uid and isBlocked = 1 order by id desc")
fun getBlockedConnections(uid: Int): PagingSource<Int, ConnectionTracker>

@Query(
"select * from ConnectionTracker where (appName like :query or ipAddress like :query or dnsQuery like :query or flag like :query or proxyDetails like :query or connId like :query) and isBlocked = 1 order by id desc"
)
fun getBlockedConnections(query: String): PagingSource<Int, ConnectionTracker>

@Query(
"select * from ConnectionTracker where uid = :uid and (appName like :query or ipAddress like :query or dnsQuery like :query or flag like :query or proxyDetails like :query or connId like :query) and isBlocked = 1 order by id desc"
)
fun getBlockedConnections(query: String, uid: Int): PagingSource<Int, ConnectionTracker>

@Query(
"SELECT uid, ipAddress, port, COUNT(ipAddress) as count, flag as flag, 0 as blocked, GROUP_CONCAT(DISTINCT dnsQuery) as appOrDnsName, SUM(downloadBytes) as downloadBytes, SUM(uploadBytes) as uploadBytes, SUM(downloadBytes + uploadBytes) as totalBytes FROM ConnectionTracker WHERE uid = :uid and timeStamp > :to GROUP BY uid, ipAddress, port ORDER BY count DESC"
)
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/java/com/celzero/bravedns/database/DnsLogDAO.kt
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,19 @@ interface DnsLogDAO {
@Query("select * from DNSLogs order by id desc LIMIT $MAX_LOGS")
fun getAllDnsLogs(): PagingSource<Int, DnsLog>

@Query("select * from DNSLogs where uid = :uid order by id desc LIMIT $MAX_LOGS")
fun getAllDnsLogs(uid: Int): PagingSource<Int, DnsLog>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[performance · low]
The DNSLogs table has no index on the uid column (the DnsLog entity only indexes queryStr, responseIps, isBlocked, blockLists, time). These new uid-filtered queries cannot leverage an index, forcing a table scan on every page load — and the table can grow well beyond the MAX_LOGS (35000) cap between date-based purges. Since this file already documents performance sensitivity for this table (the LIMIT $MAX_LOGS comment), consider adding an index on uid to the DnsLog entity (e.g., Index(value = arrayOf("uid"))) so the TV logs filter stays fast.

Suggestion:

Suggested change
fun getAllDnsLogs(uid: Int): PagingSource<Int, DnsLog>
@Index(value = arrayOf("uid"), unique = false)


@Query(
"select * from DNSLogs where (queryStr like :searchString or responseIps like :searchString or appName like :searchString) order by id desc LIMIT $MAX_LOGS"
)
fun getDnsLogsByName(searchString: String): PagingSource<Int, DnsLog>

@Query(
"select * from DNSLogs where uid = :uid and (queryStr like :searchString or responseIps like :searchString or appName like :searchString) order by id desc LIMIT $MAX_LOGS"
)
fun getDnsLogsByName(searchString: String, uid: Int): PagingSource<Int, DnsLog>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[test · low]
The new overloaded DAO queries (getAllDnsLogs(uid) and getDnsLogsByName(searchString, uid)) are not covered by the existing DnsLogDAOTest.kt. Since these queries introduce uid-filtering logic (including the combined uid + LIKE search predicate) that is relied upon by the TV logs screen, consider adding tests that insert logs with different uids and verify the returned results are correctly filtered and ordered.


@Query("select * from DNSLogs where proxyId like :wgDnsId order by id desc LIMIT $MAX_LOGS")
fun getDnsLogsForWireGuard(wgDnsId: String): PagingSource<Int, DnsLog>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ interface ODoHEndpointDAO {
@Query("select * from ODoHEndpoint order by isSelected desc")
fun getODoHEndpointLiveData(): PagingSource<Int, ODoHEndpoint>

@Query("select * from ODoHEndpoint order by isSelected desc")
suspend fun getAllAsList(): List<ODoHEndpoint>

@Transaction
@Query(
"select * from ODoHEndpoint where resolver like :query or name like :query order by isSelected desc"
Expand Down
Loading
Loading