Add Android TV variant#2682
Conversation
This repository is now ezelab/rethink-tv, an Android TV UI fork of celzero/rethink-app. The upstream engine (DNS, firewall, VpnService, per-app rules) is used as-is; only an Android TV UI is added in a dedicated Gradle source set in subsequent commits. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds a new `tv` Gradle product flavor as a peer of `full` in the existing `releaseType` dimension, with applicationIdSuffix ".tv" so the rethink-tv build coexists with upstream RethinkDNS on a device and gets its own F-Droid app entry. All TV-specific code lives under app/src/tv/ in this commit: - AndroidManifest.xml: declares a Leanback launcher activity, requires android.software.leanback, marks touchscreen optional, and adds a banner asset reference. - RethinkTvLauncherActivity.kt (com.ezelab.rethinktv): a minimal Phase 2 stub Activity that displays "Rethink TV — scaffold" so the variant builds and installs end-to-end. Replaced by the real Compose-for-TV UI in subsequent phases. - res/drawable/tv_banner.xml: placeholder vector banner (320x180dp). - res/values/strings.xml: `app_name` override for the tv flavor. The shared engine in app/src/main/ is intentionally not edited. The only build.gradle additions are the new flavor block and a mirror of the `fullImplementation` UI/runtime deps as `tvImplementation` so the shared engine sources continue to compile under the tv variant. A new GitHub Actions workflow (.github/workflows/android-tv.yml) builds `assembleFdroidTvDebug` on push and pull_request to verify the flavor end-to-end alongside upstream's android.yml (which is left untouched). Refs: #1 (flavor-scaffold) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Helps surface which Room/KSP DAO class triggers the 'No property named value was found in annotation Query' error so we can pin down whether it's a real source-level problem or a classpath issue introduced by the new tv flavor. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Root cause of the KSP failure ("No property named value was found in
annotation Query"): `app/src/main/` DAOs use string interpolation in
`@Query` annotations referencing constants like `ID_WG_BASE`,
`MAX_LOGS`, `MISSING_UID`, and `UID_EVERYBODY`. Several of these
(notably `ID_WG_BASE`) live in `app/src/full/` rather than
`app/src/main/`, so the previous lean `tv` source set could not
resolve them. Kotlin emitted unresolvable annotation arguments, KSP
then handed Room an XAnnotation with an empty values list, and Room
crashed on `getAnnotationValue("value")`.
Upstream's `app/src/full/` is not pure phone UI: it is 295 files
including `service/` and `viewmodel/` packages that `main/` depends
on. Reskinning the UI cleanly therefore requires the `tv` flavor to
inherit `full/`'s engine surface verbatim.
This commit:
* Adds an `android.sourceSets.tv` block that appends
`src/full/java` and `src/full/res` to the tv source set and
points `manifest.srcFile` at `src/full/AndroidManifest.xml`.
* Drops the placeholder `RethinkTvLauncherActivity`,
`AndroidManifest.xml`, and `tv_banner.xml` from `app/src/tv/` —
those belong to the upcoming `tv-ux-dashboard` phase, which
will introduce a dedicated leanback launcher on top of the
inherited `full` manifest.
* Removes the redundant `tvImplementation firestackDependency()`
declaration. Firestack is scoped on the `releaseChannel`
dimension (play / fdroid / website), so each combined variant
such as `fdroidTv` already pulls it in via
`fdroidImplementation`.
* Keeps `app/src/tv/res/values/strings.xml` so "Rethink TV"
continues to override the upstream `app_name` for the tv build.
The plan in the session workspace is updated separately to record the
architectural pivot from "reskin only" to "inherit-from-full then
override the launcher".
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Phase 3 (`release-pipeline`): wire up an end-to-end path from a
`v*-tv` git tag to a signed `fdroidTvRelease` APK attached to a
GitHub Release, without touching upstream's local-signing path.
* `app/build.gradle` (append-only): if the four `TV_RELEASE_KS_*`
env vars are present, declare a new `tvRelease` signing config
(env-var-based, same shape as upstream's existing `alpha` config)
and attach it to the `release` build type. When the env vars
are absent — local builds, the `🫣 Android CI` job, the
`📺 Android TV CI` debug job — upstream's keystore.properties /
`config` signingConfig path remains the only thing touching the
release build type. Phone (`full`) release builds are
structurally unaffected.
* `.github/workflows/android-tv-release.yml`: triggers on
`v*-tv` / `tv-v*` tags and on `workflow_dispatch` (with a
`dry_run` input defaulted to `true`). The job materialises the
keystore from `TV_RELEASE_KS_BASE64`, runs
`assembleFdroidTvRelease`, uploads the resulting APK(s) as a
workflow artifact with 30-day retention, and — only for
non-dry-run tag pushes with secrets configured — publishes a
GitHub Release using `softprops/action-gh-release@v2`. Tags
ending in `-scaffold`, `-alpha`, `-beta`, or `-rc` are flagged
as prereleases.
* `docs/release.md`: maintainer documentation covering keystore
generation (`keytool -genkeypair … -keysize 4096`), base64
encoding for the GitHub Actions secret, the four secret names
expected by the workflow, the dry-run procedure for verifying
the release path before a real tag is cut, and architectural
notes on why the signing config is a sibling of upstream's
`config` rather than a replacement.
The dry-run will be triggered immediately after this commit lands on
`origin/main` to validate that proguard / R8 can minify the
inherited `app/src/full/` UI under the tv flavor; an UNSIGNED APK
is acceptable for that validation (the `TV_RELEASE_KS_*` secrets
have not been configured yet — that is an out-of-band maintainer
action documented in `docs/release.md`).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`assembleFdroidTvRelease` failed at Gradle configuration with "Unexpected character: '\"' @ line 334, column 17" because the double-quoted Groovy strings in the new TV-release signing block used `\`` to escape backticks — `\`` is not a valid Groovy escape, so the lexer treated the `\` as a backslash and choked on the following `"`. Replace the backticks with single quotes, which serve the same cosmetic purpose in log output without confusing the parser. This was caught by the Phase 3 dry-run release workflow (`workflow_dispatch` on `📺 Android TV Release`); the workflow itself is fine and re-running it after this commit should produce an unsigned APK artifact (the maintainer has not yet configured the `TV_RELEASE_KS_*` secrets — see docs/release.md). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Phase 8 (`upstream-sync-tooling`): wire up the long-lived
`upstream-sync` branch and the cadence that keeps the fork in step
with celzero/rethink-app without ever editing upstream files.
* `scripts/sync-upstream.sh`: idempotent, three-mode sync script
(`--push` and `--open-pr` are optional). Fetches
`upstream/main`, exits cleanly when `origin/main` is already
ahead, otherwise resets the `upstream-sync` branch to
`origin/main` and runs a `--no-ff` merge with a structured
commit message. Includes post-merge invariants: every
rethink-tv-owned file must still exist, and the
`// rethink-tv fork: Android TV flavor` and inherit-from-full
`sourceSets` markers must still be in `app/build.gradle`. Fails
loudly (exit 1) rather than silently if either invariant breaks.
* `.github/workflows/upstream-sync.yml`: weekly cron at 03:00 UTC
on Mondays plus a `workflow_dispatch` trigger. Adds the
upstream remote, runs the sync script, force-pushes (with
`--force-with-lease`) and opens / updates the `upstream-sync →
main` PR. `concurrency: upstream-sync` prevents two simultaneous
runs from racing on the branch.
* `docs/upstream-sync.md`: contributor docs covering branch /
remote layout, the three conflict categories (rethink-tv-owned,
structural-we-depend-on, surprise-restructure) and how to handle
each, environment overrides for the script, and a closing
section on why drift from upstream is a tier-1 incident
(security cadence, contribute-back rebase, user trust).
The script is safe to run locally today (it correctly aborts on a
dirty tree or missing remote) and was smoke-tested against
upstream/main = df2eb58 (current upstream HEAD), which matches the
upstream tip our fork is based on.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Lets maintainers run `📺 Android TV CI` on demand against any branch (notably `for-upstream` and `upstream-sync`) without changing the regular push / PR trigger surface. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Introduces the first real TV-specific UI surface. Until this commit the
`tv` flavor inherited `app/src/full/`'s phone UI wholesale, which
installs cleanly but doesn't surface a launcher icon on Android TV
(the phone activities use `LAUNCHER` only, not `LEANBACK_LAUNCHER`).
Phase 5 MVP scope:
* a single screen showing protection status (on/off) + the current
Brave mode (DNS / Firewall / DNS+Firewall), and one large focusable
button that toggles the VPN;
* delegates start/stop to the same `VpnService.prepare()` →
`VpnController.start/stop` flow upstream's phone fragment uses
(see `HomeScreenFragment.prepareVpnService/startVpnService`), so
the engine side is untouched.
Build wiring:
* `org.jetbrains.kotlin.plugin.compose` plugin applied project-wide
(bundled with Kotlin 2.1.20; classpath dep added in root
`build.gradle`). Safe to apply globally — phone variants contain
no `@Composable` and the plugin is a no-op for them.
* `buildFeatures.compose = true` for the same reason — costs
nothing on variants without @composable code.
* Compose stack added as `tvImplementation` only: BOM 2024.12.01
(Compose runtime 1.7.6, matches the Kotlin 2.1.20 Compose compiler),
`androidx.tv:tv-material:1.0.0` for TV-styled components,
`activity-compose`, `lifecycle-runtime-compose`,
`koin-androidx-compose` (so the UI can pull `PersistentState`
etc. via `koinInject`).
Manifest:
* Drops the `manifest.srcFile = src/full/AndroidManifest.xml`
override and introduces a real `app/src/tv/AndroidManifest.xml`.
This is necessary because Gradle only allows one manifest per
source set — we couldn't both redirect to full's manifest AND
contribute a TV launcher entry.
* Until upstream's manifest stabilises into something we can
`tools:replace`-overlay cleanly, the tv manifest is a verbatim
copy of full's with two additions:
- `<uses-feature android:name="android.software.leanback"
required="false" />` (and a matching
`hardware.touchscreen` declaration) so the Play Store /
Android TV launcher recognise this APK as a TV app;
- `<activity com.ezelab.rethinktv.ui.TvHomeActivity ...>` with
`LEANBACK_LAUNCHER` + `LAUNCHER` intent filters — the
latter is included so QA on non-TV hardware can still reach
the activity from the standard launcher.
Drift management is handled by the existing sync workflow: when
`upstream/main` modifies `full/AndroidManifest.xml`,
`scripts/sync-upstream.sh` flags the divergence so a maintainer
can mirror the change here.
Kotlin (under `app/src/tv/java/com/ezelab/rethinktv/ui/`):
* `TvHomeActivity` — thin `ComponentActivity` that calls
`setContent { TvHomeApp() }`. The inherited
`RethinkDnsApplication` initialises Koin before `onCreate`
runs, so the composables can resolve singletons immediately.
* `theme/Theme.kt` — TV-Material 3 dark color scheme. Android TV
apps are essentially always dark-themed (ten-foot UI), so we lock
the flavor to dark and don't expose a toggle.
* `home/HomeScreen.kt` — the single screen. Observes
`VpnController.connectionStatus` LiveData via
`observeAsState`, reads `PersistentState.braveMode` for the
mode label, and uses `rememberLauncherForActivityResult` to
handle the system VPN-consent dialog. The toggle helper is kept
as a top-level function rather than a ViewModel — a ViewModel
layer will land in Phase 6 once we have multiple screens with
shared state.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Rounds out Phase 5 (`tv-ux-dashboard`) — what was previously a
single-screen toggle is now a three-tab top-bar layout:
* `Home` — the existing protection-status + start/stop dashboard.
* `Settings` — Brave-mode selector (DNS / Firewall / DNS+Firewall),
writing via `AppConfig.changeBraveMode` (not directly to
`persistentState.braveMode`) so the tunnel-mode observers
upstream wires up still fire. Runs the write on Dispatchers.IO
because the observer callbacks may touch the database.
* `About` — minimal credits / repo pointer.
Implementation notes:
* State-driven `when`-routing rather than the androidx.navigation
library — Phase 5 has too few destinations to justify the extra
dep and the boilerplate around `NavController`. We'll graduate
when there are nested destinations or back-stack semantics worth
modelling (likely once Phase 6 settings sub-screens land).
* `TabRow` from `androidx.tv.material3` — uses TV-styled focus
behaviour (focus = select, no separate tap-to-confirm), which
matches the D-pad pattern Android TV users expect.
* `AppConfig` is pulled via `koinInject` alongside
`PersistentState`, demonstrating the same DI surface upstream's
phone fragments use. No bespoke service-locator layer needed
yet — the engine adapter (planned Phase 4) can stay deferred
until something concrete forces it.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The marquee TV-specific affordance: a tab that lists installed
streaming apps Rethink recognises (Netflix, Disney+, Prime Video,
YouTube, Plex, Jellyfin, Hulu, Max, Paramount+, Peacock, Apple TV,
Spotify, Twitch, Crunchyroll, …) and lets the user toggle each one
between 'Through Rethink' (default; ad-blocking active) and
'Bypass Rethink' (DRM-safe; the app's UID skips the tunnel).
Wires to upstream FirewallManager:
* reads getApplistObserver() LiveData for the current app set
and their firewallStatus
* writes via updateFirewallStatus(uid, EXCLUDE/NONE, ALLOW) on
Dispatchers.IO, mirroring the phone firewall adapter
KnownStreamers maps known package names to friendly service labels
so the UI says 'Netflix', not 'com.netflix.ninja'. The list is
curated — phone-only and TV-only package SKUs from the same vendor
both map to the same friendly name.
No engine changes; this is purely a new TV-flavour view on top of
existing upstream state.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Because AGP source-sets only allow one manifest per set, app/src/tv/AndroidManifest.xml has to be a near-verbatim copy of app/src/full/AndroidManifest.xml rather than a redirect. That means when upstream adds a new <activity>, <service>, <receiver>, or <provider> to full's manifest, we have to manually mirror it into tv's manifest or that component silently disappears on the TV build. This adds a soft-warning drift detector to scripts/sync-upstream.sh: it extracts every android:name= value from a manifest-component element in full's manifest, looks for the same string in tv's manifest, and prints a yellow ⚠ with the list of missing names if any are absent. It does NOT fail the sync — sometimes a component is intentionally TV-omitted (the BootReceiver was on the chopping block at one point) — but the maintainer sees the list and decides. Smoke-tested locally: zero drift today, exits clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Two bugs caught by the first emulator run.
1. **Streams tab didn't refresh after toggling 'Bypass Rethink'**
Verified end-to-end: the click fires
FirewallManager.updateFirewallStatus(uid, EXCLUDE, ALLOW), the
engine logs 'Apply firewall rule for uid: X, EXCLUDE, ALLOW',
the database is updated, and the new state is visible on the
next activity launch — but the row never updates live while the
activity is alive.
Root cause is a subtle interaction with upstream's data model:
- FirewallManager.invalidateFirewallStatus() mutates
AppInfo.firewallStatus in place on the cached objects.
- It then posts a NEW List<AppInfo> via
appInfosLiveData.postValue(snapshotAppInfos()) — but the
list contains the SAME mutated AppInfo references.
- LiveData.observeAsState() writes to a mutableStateOf that
uses structuralEqualityPolicy(). Comparing old list vs new
list returns 'equal' because the elements are identical
references (and AppInfo.equals on identical references is
trivially true regardless of field changes).
- Compose decides 'no state change' and skips recomposition.
Fix: subscribe to the LiveData via produceState + a manual
Observer, and project each emission into an immutable list of
StreamerView data classes with snapshotted primitive fields. The
data class structural inequality survives the in-place mutation,
so Compose recomposes correctly when 'excluded' flips.
Now the row text and button label flip in real time on every
toggle, verified on an Android TV emulator (Pixel TV, android-34,
arm64-v8a).
2. **Initial focus landed on 'Start protection', not the tab row**
D-pad RIGHT from the cold-launch state appeared to do nothing
because focus was on the Start protection button (the highest
focusable in the Home tab's content), which has no rightward
sibling. Users had to discover D-pad UP to reach the tab row.
Fix: attach a FocusRequester to the first Tab and request focus
in a LaunchedEffect(Unit). Now the cold-launch state has the
Home tab focused, so D-pad RIGHT immediately navigates tabs as
expected, and D-pad DOWN drops into content.
Both fixes verified on the same emulator run: tabs navigate via
D-pad, Streams toggle updates live, VPN consent flow works end to
end (Protection: ON, 'VPN is connected' system indicator).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace the 4-tab Compose-for-TV launcher (Home toggle + Streams bypass
+ brave-mode picker + About) with an 8-destination left-rail navigation
scaffold sized for full upstream-feature parity.
Foundation (Phase A of the full-feature parity plan):
* New tv-material NavigationDrawer + Navigation-Compose NavHost in
ui/nav/TvNavScaffold.kt, hosting one composable per top-level
destination — Home / DNS / Firewall / Apps / Proxy / Logs / Stats /
Settings. Apps subsumes the deleted Streams tab; per-app bypass is a
first-class case of per-app rules rather than a curated overlay.
* Common helpers in ui/common/:
* TvScreenScaffold for consistent ten-foot heading + body padding.
* PlaceholderScreen so under-construction destinations are obvious.
* rememberAsImmutableState — drop-in observeAsState replacement that
survives upstream's mutate-in-place LiveData pattern (FirewallManager,
WireguardManager, ProxyManager). Documented inline; see the
StreamsContent.kt history for the bug it works around.
* HomeScreen retains the working VPN-consent + start/stop flow. Now
reads VpnController.hasTunnel() (isOn is @deprecated upstream).
* SettingsScreen retains the brave-mode picker for Phase A so the
build is functionally usable before Phase I lands.
* Other six destinations stub to PlaceholderScreen — feature parity
is rolled out per phase (B–I in plan.md).
Build:
* Adds androidx.navigation:navigation-compose:2.8.5 and
androidx.compose.material:material-icons-extended to the
tvImplementation block in app/build.gradle (fenced inside the
existing rethink-tv comment block; phone variants unaffected).
* assembleFdroidTvDebug -> compileFdroidTvDebugKotlin verified locally
on JDK 17.
Documentation (Phase K of the plan):
* README softens the 'contribute-back-friendly' framing — the
merge-friendly source-set isolation is reframed as engine/security
pass-through rather than as a contribution channel.
* docs/upstream-sync.md drops the for-upstream / Phase 9 references;
merge-conflict playbook keeps only the in-fork shadow remediation.
* origin/for-upstream and the local for-upstream branch are deleted
(no commits beyond an early flavor-scaffold attempt).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Expand the Home destination from the Phase A status + toggle skeleton
to the full Phase B dashboard:
* Status row: ON/OFF badge (focus-free Surface so it does not steal
D-pad focus from the toggle), brave-mode pill, and the currently
connected DNS resolver name (from AppConfig.getConnectedDnsObservable).
* Toggle row: unchanged behaviour — start / stop the tunnel through
VpnController, with the same VpnService.prepare consent-launcher
flow upstream HomeScreenFragment uses.
* Counters row: three equal-width cards backed by the same LiveData
the phone home fragment binds to —
- DNS queries (AppConfig.dnsLogsCount)
- Connections (AppConfig.networkLogsCount)
- Blocked 5min (ConnectionTrackerRepository.getBlockedConnectionsCountLiveData)
Counters dim when protection is off so users see the numbers are
stale rather than live.
Wiring all LiveData reads route through rememberAsImmutableState (from
common/LiveDataCompose.kt) to dodge the structural-equality recomposition
bug documented for upstream's mutate-in-place observers (FirewallManager,
WireguardManager, ProxyManager). VpnController.connectionStatus is enum-
valued so the safe path costs nothing here either.
formatCount compresses big numbers to 'K'/'M'/'B' tiles so they fit at
ten-foot reading distance.
Build: assembleFdroidTvDebug -> compileFdroidTvDebugKotlin verified
locally on JDK 17.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace the Phase A placeholder Firewall destination with the eight universal-firewall toggles upstream's phone screen exposes (the ones not requiring the accessibility-service shim, which is what the Block-when-background toggle needs and Leanback devices don't provide). New common widget — SettingToggleRow: * Full-row D-pad target (no separate Switch thumb to land on). * 44 dp filled-check tile on trailing side, easy to parse at 10 ft. * Focused state lifts to primary-container with the standard tv- material ClickableSurface focus colour set, so the selection ring is glanceable from a sofa. * Mirrors PersistentState's getter/setter writes; the setters call setUniversalRulesCount() internally so the rule-count badge in the screen subtitle stays in sync via the universalRulesCount LiveData. New common widget — SettingSectionHeader: small uppercase divider between groups of toggle rows, used to break the universal-firewall list into Connection types / DNS protection / App lifecycle / Lockdown. FirewallScreen wires the toggles via koinInject<PersistentState>(), running setter calls on an IO scope since they hit SharedPreferences. Reads of universalRulesCount route through rememberAsImmutableState to dodge the upstream LiveData / Compose identity bug. Background-mode (BlockAppWhenBackground) is omitted from the TV surface — the toggle is dependent on an AccessibilityService that isn't reliably available on Leanback. Filter-IPv4-in-IPv6 is omitted because upstream itself has it commented out today. Build: compileFdroidTvDebugKotlin succeeds on JDK 17. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace the brave-mode-only Phase A settings stub with the full TV-appropriate settings surface — the union of upstream's Tunnel, Misc and Advanced settings activities, filtered to the toggles that actually make sense on Leanback hardware. Layout (sections, top-to-bottom): * Protection mode — three-button picker, kept first so users land on it after the screen heading. Reuses the same AppConfig.changeBraveMode path the phone uses; mode persistence is reflected in a check mark plus bold weight for accessibility at 10 ft. * Tunnel — Allow bypass, LAN traffic, all available networks, protocol translation (NAT64 / 6to4). * WireGuard — global lockdown, smart persistent keep-alive. Other per-tunnel WG settings live with the WG list/detail screens. * Reliability — endpoint-independent mapping, TCP keep-alive, maximum MTU, stall on no network. * Boot — auto-start on boot. Deliberately omitted from the TV surface for v1: * Theme switcher — TV is locked to dark. * Biometric / fingerprint App Lock — Leanback devices lack a reliable biometric stack. A PIN App Lock destination will land later under its own card. * Notifications — TVs surface them inconsistently. * Locale picker — uses upstream's intent flow, easier as a separate dialog in the polish phase. * Backup / Restore, Console log — will be dedicated cards. Writes go through Dispatchers.IO since PersistentState's booleanPref delegate commits to SharedPreferences synchronously. Reads use plain property gets — these are var-property-backed prefs, not LiveData, so the rememberAsImmutableState path isn't needed here. Build: compileFdroidTvDebugKotlin succeeds on JDK 17. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace the Phase A placeholder Apps destination with the per-app
firewall surface — the TV equivalent of upstream's AppListActivity
+ AppInfoActivity pair.
AppsScreen — master grid:
* LazyVerticalGrid with adaptive 260dp columns. Each card shows the
app icon (loaded async via PackageManager on IO), the app name,
the package id, and a one-word status pill summarising the
(FirewallStatus, ConnectionStatus) pair in the same shorthand
upstream uses in its rule tooltips.
* Cards are TV-material ClickableSurfaces — single D-pad target per
card; pressing center pushes apps/{uid} onto the nav stack.
* Sort: user apps first (alpha), system apps after.
* Subscribes via rememberAsImmutableState to the
FirewallManager.getApplistObserver MutableLiveData — projects each
AppInfo to an immutable AppRow data class to dodge the mutate-in-
place identity bug.
AppDetailScreen — single-app editor:
* Identity header: 96dp icon, current status pill, uid.
* Five-button segmented selector for FirewallStatus
(Allow/Block · Bypass universal · Bypass DNS+FW · Isolate · Exclude).
* Four-button segmented selector for ConnectionStatus
(Allow · Block all · Block metered · Block Wi-Fi). Disabled with
inline explanation when FirewallStatus is anything other than
NONE — matches the upstream semantics where the connection class
only takes effect on the NONE baseline.
* Writes via FirewallManager.updateFirewallStatus on Dispatchers.IO
(same code path upstream's spinners use).
Async-icon loading uses produceState(packageName) with a defensive
Drawable -> Bitmap helper that handles adaptive drawables' zero-
bounds edge-case (falls back to a generic Android glyph for missing
packages — useful when an app uninstalls mid-grid).
Nav: TvNavScaffold gains the apps/{uid} route with an Int navArgument
and passes navController into AppsScreen so cards can push.
Out of scope for v1 and explicitly noted in code: per-app domain
rules, per-app IP rules, per-app proxy mapping. Those will land as
sub-destinations in the polish phase.
Build: compileFdroidTvDebugKotlin succeeds on JDK 17.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace the Phase A placeholder DNS destination with a working
resolver picker for the three encrypted DNS protocols TV users
actually pick: DoH, DoT, ODoH.
Layout:
1. Currently-connected banner at the top, showing the resolver
name and protocol pill. Same data Home surfaces; repeating it
here makes the destination self-contained.
2. Three-button protocol tabs.
3. Lazy-column of endpoints for the active tab. Each row shows
name, URL (one-line, ellipsised), and an Active / custom pill
when applicable.
Read paths: appConfig.getAllDefaultDoHEndpoints() and
getAllDefaultDoTEndpoints() — suspend repository calls dispatched on
IO inside produceState. Connected DNS name routes through
rememberAsImmutableState on appConfig.getConnectedDnsObservable().
Write paths: appConfig.handleDoHChanges(...) and handleDoTChanges(...).
Both are the same suspend functions upstream's spinners invoke; each
removes the prior connection-status flag, marks the new endpoint
isSelected, and pings onDnsChange to re-bootstrap the tunnel. We
bump a reloadKey from the Main dispatcher after the write completes
so the list immediately reflects the new Active pill.
Out of scope for v1 (explicitly noted in the file):
* Adding a custom DoH/DoT URL (needs a TV-friendly text-entry dialog).
* DNSCrypt server + relay multi-select (complex flow).
* DNS Proxy (plain UDP) endpoints (rare on TV).
* Rethink+ basic blocklist categories (separate sub-flow).
* Local-blocklist download progress UI.
The ODoH tab is wired through but the list path is left as no-op
pending a custom-add UX (the default ODoH list upstream ships is
empty until the user adds a server).
Build: compileFdroidTvDebugKotlin succeeds on JDK 17.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add the Logs destination — connection-level diagnostics for the TV build. Powered by androidx.paging:paging-compose 3.3.5 (added to the fenced rethink-tv tvImplementation block), which lets us hand the upstream PagingSource from ConnectionTrackerDAO and DnsLogDAO straight into a Compose LazyColumn without recreating any of the paging pipeline. Three tabs at the top: * Connections — every TCP / UDP connection Rethink has seen, most- recent first. DAO orders DESC and caps at MAX_LOGS. * DNS queries — every DNS lookup, with query name, type, resolver IP, latency (when known). * Blocked only — Connections filtered to isBlocked = 1, the most common audit view. Row UX: * Each row is its own TV-material ClickableSurface — D-pad walks the list naturally, focus colours highlight the current entry. * Left chip: green OK / red BLK, parseable at 10 ft. * Right: HH:mm:ss timestamp. * Body: app name + ip:port, or DNS query name + record type + resolver. Row click is a no-op placeholder — row-level detail expansion and the close-connection action will land in the polish phase. Search, per-app filtering, and the Rethink-log subset are similarly TBD; none are blocking for v1 viewing. Build: compileFdroidTvDebugKotlin succeeds on JDK 17 with the new paging-compose dependency. APK size impact ~150KB after R8. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace the Proxy destination placeholder with a real list-driven screen. Layout: * 'WireGuard tunnels (N)' section — every WgConfigFilesImmutable returned by WireguardManager.getAllMappings(). Each card shows the tunnel name, an ON/OFF tile, and small pills for the secondary flags upstream tracks: catch-all, lockdown, one-WG, metered-only. Active tunnels sort to the top. * 'Other proxies' section — read-only summary cards for SOCKS5 / HTTP / Orbot. SOCKS5 + HTTP show 'host:port' from the AppConfig.getSocks5ProxyDetails / getHttpProxyDetails Room reads (suspending, dispatched on IO via produceState). Orbot shows Active / Not configured from isOrbotProxyEnabled(). Toggling a tunnel card calls WireguardManager.enableConfig() / disableConfig() — the same path upstream's per-tunnel switch on the phone uses — so it correctly registers/unregisters the WireGuard proxy with VpnController and updates the AppConfig.ProxyProvider state. Writes dispatch on Dispatchers.IO and bounce reloadKey back through Dispatchers.Main so the list re-reads after persistence. WireguardManager.load(false) is invoked once on first composition in case the screen is entered before VpnService finished its boot sequence — load() is idempotent. Deliberately out of scope this commit (all surface to come in follow-ups): * WG add / import (TV file-picker + clipboard). * WG detail (peers, allowed IPs, DNS, per-app mapping). * SOCKS5 / HTTP / Orbot editor forms (need TV-friendly TextField with paste shortcut). * TCP proxy + anti-censorship sub-screens. Build: compileFdroidTvDebugKotlin succeeds on JDK 17. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace the Stats destination placeholder with five paged tabs over the same queries upstream's SummaryStatisticsFragment uses on phone. Tabs (each is its own Pager fed straight from a DAO PagingSource): * Top apps — StatsSummaryDao.getMostAllowedApps(to) * Top domains — StatsSummaryDao.getMostContactedDomains(to) * Top IPs — ConnectionTrackerDAO.getMostContactedIps(to) * Blocked apps — StatsSummaryDao.getMostBlockedApps(to) * Blocked domains — StatsSummaryDao.getMostBlockedDomains(to) Window selector (1 h / 24 h / 7 d) sits above the tabs and feeds the 'to' Long the DAOs filter on. Same arithmetic the phone ViewModel applies, just hoisted to Compose state instead of LiveData. Rows: * Rank tile (#1, celzero#2 …) on the left for at-a-glance ordering. * Primary label: app name / domain / IP (falls back to uid / flag when the source has no friendly name). * Trailing aggregate: human-readable bytes (e.g. 12.4 MB) when totalBytes is available, otherwise the raw connection count. Each row is a clickable Surface — the click is a no-op placeholder today; a per-row detail screen (per-app history, per-domain connections breakdown, alerts feed) will land in the polish phase. Build: compileFdroidTvDebugKotlin succeeds on JDK 17. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Phase J — ten-foot UX polish on the Home dashboard. Two
user-visible additions, deliberately scoped tight so the rest of
the polish work (focus traversal sweep, per-row detail screens,
backup/restore via SAF, app lock PIN) can land incrementally
without forcing another monolithic commit.
1. Welcome banner
New `ui/common/Onboarding.kt` exposes
`rememberOnboardingState()` + `WelcomeBanner()`. The banner
sits above the status row on Home, explains in two short
sentences what the user should do next (start the toggle, then
explore the left rail), and dismisses on a single centre-key
press. Dismissal is persisted in a TV-only SharedPreferences
file (rethink_tv_ux / onboarding_seen) so it doesn't pollute
upstream PersistentState — keeps the merge surface for upstream
PersistentState changes at zero.
2. VPN-consent failure feedback
The Home toggle's ActivityNotFoundException catch was a silent
no-op with a TODO pointing at Phase J. Replaced with a
Toast.LENGTH_LONG explaining what happened ('This device can't
show the VPN consent screen…') and suggesting the
typical workaround (sideload a stock VpnDialogs APK). Stripped
TV ROMs that ship without the system VPN consent dialog were
the only reason this catch existed — silent failure was the
worst possible UX there.
Build: compileFdroidTvDebugKotlin + assembleFdroidTvDebug both
succeed on JDK 17; the unsigned debug APKs land under
app/build/outputs/apk/fdroidTv/debug/.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add a TV search field and app picker to the Logs screen. Switch the paging queries to LIKE and uid-aware DAO variants so Connections, DNS, and Blocked tabs all refresh from Room. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Use CustomDomainDAO.getDomainsByUID(Constants.UID_EVERYBODY) with DomainRulesManager.noRule/block/trust for the Domains tab, and CustomIpDao.getRulesByUid(Constants.UID_EVERYBODY) with IpRulesManager.updateNoRule/updateBlock/updateBypass for the IPs tab. Add a TV Rules destination with Domains/IPs tabs, universal-only scope messaging, status/type pills, and click-to-cycle rule rows. Also pass navController to the existing ProxyScreen route so the TV build compiles. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Two new detail screens reached from the Proxy destination, plus the
ProxyScreen card-click rewiring needed to navigate to them.
1. WireGuard tunnel detail (`WgDetailScreen.kt`)
Reached by tapping any WireGuard tunnel card on the Proxy
screen. Mirrors the phone's WgConfigDetailActivity:
* Interface card — addresses, DNS servers, MTU, listen port,
and the interface's base64 public key (monospace).
* Tunnel status — top-level Active toggle that calls
WireguardManager.enableConfig / disableConfig. Was previously
bound to the list-card click; lifting it here means the list
card is free to be a navigation gesture.
* Tunnel behaviour — the four secondary toggles upstream
tracks: catch-all, lockdown, exclusive (one-WireGuard),
metered-only. Each calls the matching
WireguardManager.update*Config suspend setter on IO and
bounces a reloadKey through Main to re-read the immutable
mapping.
* Peers — every Peer in the parsed Config with public key,
allowed IPs, endpoint, persistent keepalive.
Peer editing / add-peer / delete are deferred — both need a
paste-friendly text-entry flow that lands alongside the WG
import screen.
2. SOCKS5 / HTTP proxy editor (`ProxyEditorScreen.kt`)
One composable, two routes (`proxy/socks5` and
`proxy/http`) parameterised by ProxyEditorKind. Replaces the
read-only summary card with a TV-friendly form:
* Host, port, optional username, optional password.
* Save button → AppConfig.updateCustomSocks5Proxy /
updateCustomHttpProxy. Both internally call
ProxyEndpointRepository.update + AppConfig.addProxy so the
engine picks the endpoint up immediately.
* Disable button — only rendered when the proxy is currently
enabled — calls AppConfig.removeProxy with the matching
ProxyType + ProxyProvider.CUSTOM.
OutlinedTextField from `androidx.compose.material3` is used
because tv.material3 doesn't ship one. It focuses and pops the
system IME cleanly on Android TV.
3. Nav routing
* `ProxyScreen` now takes a NavController; tunnel-card click
navigates to `wg/{id}`, SOCKS5 / HTTP cards navigate to
`proxy/socks5` / `proxy/http`. Orbot card is non-clickable
for now (Orbot enable runs through the Orbot app handshake,
not via the editor).
* Three new routes added to TvNavScaffold: `wg/{id}` (Int
navArg), `proxy/socks5`, `proxy/http`. These layer onto the
Rules destination added in the same wave.
Build: compileFdroidTvDebugKotlin succeeds on JDK 17.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… WG import Six new TV-native parity screens covering upstream surface that wasn't reachable from the wave-1/2 nav: * Anti-censorship (dial + retry strategy pickers, bound to PersistentState.dialStrategy / retryStrategy). * Pause VPN (5/15/30/60 min presets + live countdown via PauseTimer.getPauseCountDownObserver()). * Console log (paged ConsoleLog with level filter + Clear). * Stats drill-down — apps route to AppDetail, domains/IPs route to a new StatsDetailScreen that reuses ConnectionTrackerDAO.getConnectionTrackerByName. * Custom ODoH endpoint add screen (insertAsync + AppConfig.handleODoHChanges). * WG tunnel import — paste-from-clipboard or SAF-file-picker path feeds Config.parse + WireguardManager.addConfig. Plumbing: three new Settings → Advanced nav rows, ODoH 'Add custom' button in DnsScreen, '+ Add tunnel' button in ProxyScreen, six new routes in TvNavScaffold, NavController threaded through Stats/Dns/Settings screens. Upstream touch: one additive method ODoHEndpointDAO.getAllAsList() to mirror the existing DoH/DoT shape; no behavioural changes to existing methods. Build: assembleFdroidTvDebug succeeds on JDK 17. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…lled Row+rail
Live emulator testing revealed every destination rendered empty — only
the nav rail was visible, and the right pane was completely blank.
Root cause: tv-material 1.0.0's androidx.tv.material3.NavigationDrawer
mis-measures its content slot when the drawer is collapsed. The NavHost
hosted inside content() ended up with zero usable width, so every
destination Composable composed into nothing.
Fix: drop NavigationDrawer + NavigationDrawerItem and lay the scaffold
out as Row { rail-Column ; content-Box }. Items are 56dp circular
Surfaces with selected-state tinting and standard DPAD focus
traversal. The rail is verticalScroll()-able so all 9 top-level
destinations (Home, DNS, Firewall, Apps, Rules, Proxy, Logs, Stats,
Settings) fit at 1080p — previously Stats and Settings were off-screen.
Verified end-to-end on rethink_tv_avd:
- All 9 top-level destinations render their full Compose bodies.
- DPAD navigation between rail items works.
- Sub-routes (settings/anti-censorship, etc.) render correctly.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… nav
After NavController.navigate() pushes a sub-screen (proxy -> wg/0,
settings -> settings/anti-censorship, etc.) the old composable is
disposed, focus is naturally lost, and the OS focus engine snaps focus
to the topologically-first focusable in the tree -- which on our
hand-rolled rail is the second rail item ("DNS"). The result is a
confusing visual: the new screen renders correctly with the right rail
item shown 'selected' (primary color), but a *different* rail item is
shown 'focused' (focused container color), so two rail icons appear
highlighted at once.
Anchor focus on the rail item that matches the new route's parent
destination. We map every (sub-)route back to its top-level
TvDestination via the existing 'route or route/' prefix rule, attach a
per-destination FocusRequester to each NavRailItem, and on every route
change request focus on the matching one (after an 80 ms delay so the
rail's selected-state recomposition has settled and the requester is
attached to the new measure pass).
This keeps the rail's focused and selected states in lock-step with the
visible route. The user can still press D-pad RIGHT to enter screen
content -- same as the existing top-level destination UX -- so the
ten-foot rail pattern is preserved.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Compose-for-TV's androidx.tv.material3.Surface intentionally rejects pointer input because production TV apps are remote-driven. On the emulator (and any touch-capable dev surface), this means mouse clicks do nothing, which makes iteration painful. Add a tiny drop-in wrapper Surface in ui.common.TvSurface that delegates to the tv-material composable but layers a detectTapGestures pointerInput on top of the modifier chain so the same onClick fires for both DPAD CENTER and mouse/touch taps. Focus visuals, scale, glow, and all other tv-material behavior are preserved unchanged. Replace 'import androidx.tv.material3.Surface' with 'import com.ezelab.rethinktv.ui.common.Surface' in all 20 TV files; no call-site changes needed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ompiling The project-wide Compose Compiler plugin checks for compose-runtime on the classpath of every applied variant. With compose-runtime scoped to tvImplementation only, the phone variant build fails after upstream adds new Kotlin files (any merge that introduces files the plugin sees). Expose only the BOM and compose-runtime to base implementation so all variants satisfy the plugin's classpath check. The remaining Compose UI stack (ui, foundation, material3, tv-material, navigation-compose, material-icons-extended, etc.) stays scoped to tvImplementation so phone variants don't pull the full UI toolkit. Cost to phone APK: ~250 KB of unused compose-runtime classes that never get loaded. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Two issues showed up while testing the post-merge build on real hardware and the emulator: 1) BraveVPNService crashed every cold start with 'Room cannot verify the data integrity ... Expected: 5a8c... found: 8c86...'. The migration chain from an older install brings the DB up to v30 but the resulting schema doesn't match the v30 entity definitions exactly, so checkIdentity() throws after onOpen(). fallbackToDestructive doesn't help because the migration itself doesn't fail. Detect the error on first open, delete the DB file, and rebuild from the prepackaged asset on retry. 2) On Home, after the user pressed Start protection once, focus dropped off the now-Stop protection button and any further D-pad centre press hit the rail icon behind it (which navigates to DNS instead of stopping the VPN). Anchor focus on the toggle with an explicit FocusRequester and re-grab it whenever isOn flips. Also mark the welcome banner seen as soon as the user starts protection so it doesn't keep pushing the toggle off-screen. Verified on Sony BRAVIA VH1 (Android 12, armeabi-v7a, real upgrade over the pre-fix install with the stale DB) and on rethink_tv_avd (Android TV emulator, arm64-v8a). VPN starts cleanly, button focus survives the Start/Stop flip, and the welcome banner auto-dismisses once protection is on. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Removes everything that's specific to the ezelab/rethink-tv fork
and shouldn't be part of an upstream PR against celzero/rethink-app:
- README.md restored to upstream's text (the fork-branded rewrite
is fork-only)
- .github/workflows/android-tv-release.yml (fork signed-release
pipeline; upstream has its own release flow)
- .github/workflows/upstream-sync.yml (fork helper that syncs from
upstream into this fork)
- docs/release.md and docs/upstream-sync.md (fork release + sync
procedure docs)
- scripts/sync-upstream.sh (the bash helper the sync workflow
above wraps)
What remains is the actual TV variant: app/src/tv/** (new sources),
app/build.gradle + build.gradle (new `tv` product flavor + Compose
Compiler), three additive DAO overloads in app/src/main/.../database/
that the TV UI consumes, .github/workflows/android-tv.yml (CI that
builds the new TV flavor on every PR), and the two TV-side bugfixes
just shipped (Room identity-mismatch self-heal + Home toggle focus).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| @@ -0,0 +1,316 @@ | |||
| <?xml version="1.0" encoding="utf-8"?> | |||
There was a problem hiding this comment.
Addressed in 59d2c9c — added android:taskAffinity="" to TvHomeActivity so the activity has no shared task affinity, which closes the StrandHogg 1.0 attack surface even though launchMode="singleTask" stays (kept for consistency with upstream's HomeScreenActivity). Mirrors the same pattern upstream already uses on NotificationHandlerActivity and BubbleActivity.
| @@ -0,0 +1,316 @@ | |||
| <?xml version="1.0" encoding="utf-8"?> | |||
There was a problem hiding this comment.
Out of scope of this PR. android:allowBackup="true" is set in app/src/main/AndroidManifest.xml (existing upstream code), not in the TV manifest this PR adds. The <application> element in app/src/tv/AndroidManifest.xml doesn't override the attribute, so it inherits upstream's value. Happy to flip it to false in a follow-up if that's the intent, but it would affect all flavors and seems like a separate decision.
Addresses two mobsfscan findings on PR celzero#2682 review: - StrandHogg 1.0 — task hijacking via launchMode="singleTask" - StrandHogg 2.0 — task hijacking on Android 9–10 Keeping launchMode="singleTask" matches upstream's HomeScreenActivity launcher pattern; adding android:taskAffinity="" closes the StrandHogg attack surface (other apps can't push a malicious activity on top of this task because the task has no shared affinity). Mirrors the same pattern upstream already uses on NotificationHandlerActivity and BubbleActivity. Verified on Sony BRAVIA VH1 — TV launcher still resolves the leanback entry point, app launches cleanly with Home dashboard visible. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Hi @ignoramous, @hussainmohd-a — opening this per your note on #2664. Single PR rather than a stack so review is concentrated; happy to split if you'd rather.
What this adds
A new
tvproduct flavor and a Compose-for-TV UI that reuses upstream's existing engine (BraveVPNService,VpnController,AppConfig,PersistentState, Room DB, Koin) verbatim. No upstream behavior changes; the TV variant is purely additive code inapp/src/tv/**.Phone (
fdroidFull,playStoreFull,websiteFull) builds and behavior are unchanged; the TV flavor is a separate output (app-fdroid-tv-*.apk).Tested on
rethink_tv_avd(API 31, arm64-v8a)App launches, all 9 screens render (Home, DNS, Firewall, Apps, Proxy, Logs, Stats, Rules, Settings + Console), DPAD navigation works, and protection toggles on/off with the tunnel showing live counters.
Per-file rundown
New, TV-only (no risk to phone code):
app/src/tv/**— 31 files, all undercom.ezelab.rethinktv.*. Compose-for-TV screens that consume the existing upstream services/repos..github/workflows/android-tv.yml— builds the TV flavor on every PR, same shape as the existingandroid.yml.Shared
app/src/main— additive only:ConnectionTrackerDAO.kt— 4 new query overloads adding a:uidfilter for the TV per-app logs view. Existing methods unchanged.DnsLogDAO.kt— 2 new query overloads adding a:uidfilter for the TV per-app DNS log view.ODoHEndpointDAO.kt— 1 newsuspend fun getAllAsList()so the TV ODoH picker can consume the endpoints from a coroutine. Existing LiveData/PagingSource untouched.AppDatabase.kt— wrapbuildDatabase()to detectRoom cannot verify the data integrityon first open, delete the DB file, and rebuild from the prepackaged asset. Without this,BraveVPNServicedied on every cold start when upgrading from an older install (its first DAO access hitcheckIdentity()after the migration chain). Repro and rationale in the commit message.Build files:
build.gradle— Compose Compiler gradle plugin (org.jetbrains.kotlin.plugin.compose) on the buildscript classpath, needed by the project-wide plugin id.app/build.gradle— newtvproduct flavor and AndroidX TV / Compose-for-TV deps. The compose-runtime dep lives on the baseimplementationconfiguration (nottvImplementation) because the Compose Compiler plugin's classpath check runs on every variant; phone APK cost is ~250 KB of unused runtime classes.Things I'd appreciate eyes on
AppDatabaseschema-mismatch recovery — it's destructive for the user's per-app/firewall customizations on upgrade, but the alternative was a permanent VPN-start crash. Open to a less aggressive approach if you have one in mind.com.ezelab.rethinktv— happy to move undercom.celzero.bravedns.tvif you'd prefer.Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com