Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 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
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:
runs-on: ubuntu-latest
env:
VARIANT: assembleFdroidTvDebug
steps:
- name: 🥏 Checkout
uses: actions/checkout@v6

- 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
182 changes: 182 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'
}

// apply Google Services and Firebase Crashlytics plugins conditionally
Expand Down Expand Up @@ -219,6 +223,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 @@ -258,10 +267,84 @@ android {
versionName = gitVersion
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
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']
}
}
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
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)")
}
}

configurations {
Expand Down Expand Up @@ -405,6 +488,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') {
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')
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
29 changes: 27 additions & 2 deletions app/src/main/java/com/celzero/bravedns/database/AppDatabase.kt
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,30 @@ abstract class AppDatabase : RoomDatabase() {
// Otherwise, WRITE_AHEAD_LOGGING will be used.
// Ref:
// https://developer.android.com/reference/android/arch/persistence/room/RoomDatabase.JournalMode#automatic
fun buildDatabase(context: Context) =
fun buildDatabase(context: Context): AppDatabase {
return try {
val db = newBuilder(context).build()
// Force the DB to open now so that any
// `Room cannot verify the data integrity` failure (e.g. an older
// install whose schema, after running the registered migration
// chain, no longer matches the current entity hashes) surfaces
// here instead of at the first VPN-service DAO access, which
// would crash BraveVPNService on every cold start.
db.openHelper.writableDatabase
db
} catch (e: IllegalStateException) {
val msg = e.message.orEmpty()
if ("Room cannot verify" in msg || "data integrity" in msg) {
Logger.w(LOG_TAG_APP_DB, "schema mismatch; wiping db and recreating: $msg")
context.deleteDatabase(DATABASE_NAME)
newBuilder(context).build()
} else {
throw e
}
}
}

private fun newBuilder(context: Context) =
Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, DATABASE_NAME)
.createFromAsset(DATABASE_PATH)
.addCallback(roomCallback)
Expand Down Expand Up @@ -105,7 +128,9 @@ abstract class AppDatabase : RoomDatabase() {
.addMigrations(MIGRATION_27_28)
.addMigrations(MIGRATION_28_29)
.addMigrations(MIGRATION_29_30)
.build()
// Belt and suspenders: if a migration is *missing* (not just
// mismatched), let Room destructively rebuild from the asset.
.fallbackToDestructiveMigration(dropAllTables = true)

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

@Query("select * from ConnectionTracker where uid = :uid order by id desc LIMIT $MAX_LOGS")
fun getConnectionTrackerByName(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) order by id desc LIMIT $MAX_LOGS"
)
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 LIMIT $MAX_LOGS"
)
fun getConnectionTrackerByName(query: String, uid: Int): PagingSource<Int, ConnectionTracker>

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

@Query("select * from ConnectionTracker where uid = :uid and isBlocked = 1 order by id desc LIMIT $MAX_LOGS")
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 LIMIT $MAX_LOGS"
)
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 LIMIT $MAX_LOGS"
)
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>

@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>

@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