Skip to content

feat(tv): Add Android TV / Google TV build - #116

Draft
illnar-io wants to merge 4 commits into
prajwalch:mainfrom
illnar-io:main
Draft

illnar-io wants to merge 4 commits into
prajwalch:mainfrom
illnar-io:main

Conversation

@illnar-io

@illnar-io illnar-io commented Oct 5, 2026 •

Copy link
Copy Markdown

Summary

Adds a 10-foot, D-pad-only Android TV / Google TV build alongside the handheld app.

~90% of this codebase is already portable — all 46 providers, Torznab, Room + all 8
migrations, DataStore, filters and every ViewModel are plain Kotlin/JVM with no touch
dependency. The only Android-coupled pieces are NetworkClient (bound to
android.webkit.CookieManager for the Cloudflare challenge) and ConnectivityManager,
both of which behave identically on TV.

Navigation

Every destination is reachable from a persistent left navigation rail — Search,
Browse, Bookmarks, History, Providers, Settings. Press LEFT from anywhere and you are on
the menu; each destination is one OK press away.

The rail is always composed rather than hidden behind a drawer button, and after a
section switch focus moves onto that section's entry. Without that second part the menu
shows "Settings" filled in while the focus ring is still around "Search" — the screen
contradicting the menu. The handheld app instead reaches most of these from small
top-bar icons, which is genuinely hard to find with a D-pad.

Screens covered: Search (home) → results → torrent actions → details, plus Browse,
Bookmarks, Search history, Search providers, Settings and Default sort options.

⚠️ This changes the project's release layout — see Impact below. Happy to split it
into smaller PRs or use a separate module if that's preferred.

Implementation

A device product flavor (mobile / tv) keeps the phone build untouched:

application id source set
mobile{Release,Staging,Debug} com.prajwalch.torrentsearch[.staging|.debug] app/src/mobile/
tv{Release,Staging,Debug} com.prajwalch.torrentsearch.tv[…] app/src/tv/

MainActivity moves to src/mobile (its ACTION_SEARCH / ACTION_SEND /
ACTION_PROCESS_TEXT intake and moveTaskToBack back handling have no TV analogue), and
each flavor owns its own manifest. Compose for TV is tvImplementation only.

CrashActivity now resolves the launcher activity via PackageManager instead of naming
the handheld activity, so "Restart" returns to the right entry point on both variants.

Google TV compliance

Verified against the built APK, and asserted in CI (.github/workflows/tv_build.yml):

  • ACTION_MAIN + CATEGORY_LEANBACK_LAUNCHER (TV-ML)
  • leanback, touchscreen, faketouch all required="false" (TV-MT)
  • 320x180 px banner in drawable-xhdpi with the app name baked into the image (TV-LB/TV-BN)
  • 16 KB native page alignment and both 32/64-bit ABIs (TV-G6)

D-pad adaptations

  • tv-material focus defaults are effectively invisible. ButtonDefaults.scale() and
    ClickableSurfaceDefaults.scale() both default focusedScale to 1.0f — focused
    controls do not scale at all. The border role also did not render in my build: focused
    result rows and buttons showed no ring, only a faint fill shift. Focus is now drawn with
    a foundation Modifier.border plus a scale bump.
  • Selection and focus must use different colours. primary is a near-white green
    (#A3D398) in the dark scheme, indistinguishable from a white focus ring. Selection is a
    filled primaryContainer, focus is an outline — orthogonal and composable.
  • Focus scale is a graphics transform, so it doesn't change layout bounds and
    bringIntoView parks focused items flush against the viewport edge with the enlarged
    visual clipped. Lists reserve ring space and inset past the overscan margin.
  • LazyColumn inside a Column must use weight(1f), not fillMaxSize() — otherwise
    it takes the full screen height plus the header and items below the fold become
    unreachable with the D-pad.
  • A bare TextField does not attach the IME on DPAD_CENTER. Wrapping it in a focusable
    TV Surface that focuses the field on click is what makes the keyboard appear.
  • A Compose text field consumes DPAD_UP/DPAD_DOWN for cursor movement, so BACK must
    clear focus as well as hide the IME or the remote stops navigating the screen entirely.
  • ImeAction.Search is routed to KeyboardActions.onSearch, not onDone — wiring only
    onDone silently ignores the remote's search key.
  • A disabled tv-material Button is still D-pad reachable, so it becomes a dead focus
    stop that swallows OK.
  • ModalBottomSheet has no D-pad focus containment; a Dialog does.
  • Settings toggles are full-width rows, not Switches — the thumb is a few pixels with
    no reliable D-pad hit target.
  • Dropdown menus are unusable on a remote (no focusable trigger), so dark theme, max
    results and DoH provider use radio dialogs. Max results is a ladder, not a Slider, which
    can overshoot wildly on one LEFT/RIGHT press.
  • A Switch nested inside a clickable row is a focus trap — the providers list is now
    one focusable row per provider.
  • ModalBottomSheet rows with nested trailing IconButtons (the handheld torrent
    action sheet) become single focus stops on TV.

Also worth noting: TvLazyColumn / TvLazyRow were deprecated in tv-foundation
1.0.0-alpha11 and removed in alpha12. Standard LazyColumn / LazyRow have carried the
focus behaviour since Compose Foundation 1.7.0, so guidance referring to the TV lazy layouts
is outdated.

Deliberately dropped on TV

Clipboard copy, system share, and the ACTION_SEND / ACTION_PROCESS_TEXT / global-search
entry points — all of which have no usable TV equivalent. Share / quick-search / per-app
language settings are hidden for the same reason (they toggle handheld-only activity aliases
or ACTION_APP_LOCALE_SETTINGS).

Not included yet: the Cloudflare WebView challenge flow is still handheld-only.

Impact on the existing project

  • Build tasks: assemble{Release,Staging,Debug} → assembleMobile{Release,Staging,Debug}
  • Output path: app/build/outputs/apk/<type>/ → app/build/outputs/apk/mobile/<type>/
  • CI workflows updated accordingly; a separate tv_build.yml added.
  • Any F-Droid / IzzyOnDroid recipe that builds a specific APK will need updating, and the
    TV app id needs its own store listing.

Testing

  • All six variants build clean through R8 + resource shrinking.
  • Verified on a Google TV system image (android-36;google-tv;x86_64): launcher resolves
    TvMainActivity, app launches, IME attaches on DPAD_CENTER, BACK closes the keyboard
    then the app (TV-DB), and a live search returned real results from three providers.
  • Every destination walked with the D-pad: no crashes, focus ring always lands on the
    active section, and settings toggles actually flip on OK.
  • Focus traversal is asserted programmatically: no focused node may sit outside a 48 dp
    safe area.

Adds a 10-foot, D-pad-only build alongside the handheld app, sharing all
existing business logic.

Introduces a `device` product flavor (`mobile` / `tv`). The TV variant gets
its own application id suffix so it installs side-by-side, and Compose for TV
(`androidx.tv:tv-material` / `androidx.tv:tv-foundation`, both stable since
May 2026) is only on that variant's classpath, leaving the phone APK
unaffected. Everything below the UI layer is reused unchanged: all 46
providers, Torznab, Room, DataStore and every ViewModel, because Koin's
`@ComponentScan` over the `ui` package is recursive.

Leanback manifest declares `android.software.leanback`, `android.hardware.
touchscreen` and `android.hardware.faketouch` as not-required, registers a
`CATEGORY_LEANBACK_LAUNCHER` activity, and ships a 320x180 xhdpi banner with
the app name baked into the image (criterion TV-BN). CI asserts these plus
16 KB native page alignment and 32/64-bit ABI coverage (TV-G6) against the
built APK.

Focus handling notes, since these were the parts easy to get wrong:

- `ButtonDefaults.scale()` and `ClickableSurfaceDefaults.scale()` both
  default `focusedScale` to 1.0f, so focused controls do not scale at all.
  The tv-material `border` role also did not render reliably. Focus is
  therefore drawn with a foundation `Modifier.border` plus a scale bump, on
  every focusable.
- Selection is a filled container and focus is an outline, in deliberately
  different colours. `primary` is a near-white green in the dark scheme and
  was indistinguishable from the ring.
- Focus scale is a graphics transform, so `bringIntoView` parks a focused
  item flush against the viewport edge with the enlarged visual clipped.
  Lists reserve space for the ring and are inset past the overscan margin.
- A Compose text field consumes DPAD_UP/DOWN for cursor movement, so BACK
  must clear focus as well as hiding the IME or the remote stops navigating.
- `ImeAction.Search` is routed to `KeyboardActions.onSearch`, not `onDone`.
- A bare TextField does not attach the IME on DPAD_CENTER; the field is
  wrapped in a focusable TV Surface to make it attach.
- A disabled tv-material Button is still D-pad reachable, so the blank-query
  Search button renders inert instead of becoming a dead focus stop.

`CrashActivity` now resolves the launcher activity via the package manager
rather than naming the handheld activity, so "Restart" works on both variants.

Build task names change: `assembleRelease` -> `assembleMobileRelease`, and
output moves to `app/build/outputs/apk/mobile/<type>/`. CI and docs updated.

Not included yet: Bookmarks, Browse, search history, Settings, Torznab
add/edit and the Cloudflare WebView flow are still handheld-only, so the TV
build has no navigation surface for them.
The TV build previously exposed only home, search and details, so there was no
menu and no way to reach settings, bookmarks, browse, history or providers from
a remote. Adds a persistent left navigation rail as the primary navigation
surface, plus the screens behind it.

The rail is always composed rather than hidden behind a drawer button: pressing
LEFT from anywhere lands on it, and each destination is one OK press away. The
handheld app reaches most of these from small top-bar icons, which is hard to
find with a D-pad.

After a section switch, focus moves onto that section's rail entry. Without it
the ring stays where it was, so the menu shows Settings filled in while the
focus ring is still around Search - the screen contradicting the menu, which is
the same "cannot tell what I picked" failure.

Screens added: Bookmarks, Browse, Search history, Search providers, Settings and
Default sort options. Each reuses the existing ViewModel.

D-pad adaptations:
- Settings toggles are full-width rows that flip on OK. A Switch is unusable
  with a D-pad: the thumb is a few pixels with no reliable hit target.
- Multi-value settings (dark theme, max results, DoH provider) use a radio
  dialog instead of a DropdownMenu, which has no focusable trigger on a remote.
  Max results is an explicit ladder rather than a Slider, which can overshoot
  wildly on a single LEFT/RIGHT press.
- Handheld-only rows are hidden on TV: per-app language, share integration and
  quick search all toggle activity aliases or ACTION_APP_LOCALE_SETTINGS, none
  of which exist on a TV.
- Bookmark delete moved into the torrent action dialog; the handheld delete is
  swipe-only.
- Search history no longer offers copy-query, which was long-press-only and
  copies to a clipboard that is not useful on a TV.
- Torznab edit/delete was long-press-only on handheld and is now an explicit row.
- Providers are one focusable row each instead of a Switch nested in a clickable
  item, which becomes a focus trap.
- Default sort options uses LazyColumn; the handheld plain Column has no lazy
  focus traversal, so focus can walk past the end without scrolling.

Section switching resets to that section's root rather than stacking
destinations, so BACK returns to where the user came from.
@prajwalch

prajwalch commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner

This is a huge addition. My TV is currently broken so I can't test it. Can you send me a screen recording?

It would have been much easier to test and review the code if you had submitted different pull requests with small, incremental changes.

I guess you're using an AI, right?

@illnar-io

illnar-io commented Oct 7, 2026 via email

Copy link
Copy Markdown
Author

@prajwalch

Copy link
Copy Markdown
Owner

Thanks for the reply. It seems you forgot to upload the screen recording because I am not seeing any link or direct uploads.

@prajwalch prajwalch left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Complete the suggestions

Comment thread gradle/libs.versions.toml
Comment thread app/build.gradle.kts
Comment thread README.md
activity, and ships a 320x180 xhdpi banner with the app name baked into the image. These are
checked on every CI run against the built APK, along with 16 KB native page alignment and the
presence of both 32-bit and 64-bit ABIs.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Move this section to existing Building from source section then format the guide into two subsections, one for mobile and another for tv.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Will move under 'Building from source' with Mobile/TV subsections in follow-up commit.

Comment on lines +2 to +9
<!--
TV-only strings.

Overrides live in the `tv` source set rather than `main` on purpose: the app
is Weblate-managed with `unqualifiedResLocale=en`, so anything added to
`src/main/res/values` gets pushed to all 21 translation locales. Keeping the
TV wording flavor-scoped avoids that churn for strings that only exist on TV.
-->

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Remove comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Removed.

Comment on lines +35 to +67
@Serializable
private object Home

@Serializable
private data class Search(
val query: String,
val category: Category = Category.All,
)

@Serializable
private data class TorrentDetails(
val id: String,
val detailsPageUrl: String,
val providerName: String,
)

@Serializable
private object Browse

@Serializable
private object Bookmarks

@Serializable
private object SearchHistory

@Serializable
private object Providers

@Serializable
private object Settings

@Serializable
private object DefaultSortOptions

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Why repetition? Man I am ok with AI but please take your time to actually cleanup the code properly.

Expose top level destinations from the main package and use them here.

object TopLevelDestionations {
...
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agreed. Will extract to in follow-up commit and re-export from main package.

Comment on lines +181 to +196
/** Resolves a [Category] to its localized label. */
@Composable
fun Category.displayName(): String = stringResource(
when (this) {
Category.All -> R.string.category_all
Category.Anime -> R.string.category_anime
Category.Apps -> R.string.category_apps
Category.Books -> R.string.category_books
Category.Games -> R.string.category_games
Category.Movies -> R.string.category_movies
Category.Music -> R.string.category_music
Category.Porn -> R.string.category_porn
Category.Series -> R.string.category_series
Category.Other -> R.string.category_other
},
)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Expose from the main package.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Will expose Category.displayName() from main package (ui/categoryStringResource.kt) in follow-up.


@Composable
private fun TorrentMetaText(value: String?) {
if (value == null) return

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Remove this and display the component conditionally instead.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Will replace with conditional slots: torrent.size?.let { TorrentMetaText(it) } etc. in follow-up.

Comment on lines +34 to +39
/**
* Renders the dialog state and stores a PNG for the PR. UI is driven with the
* same D-pad key injection as [TvDpadInteractionTest].
*/
@RunWith(AndroidJUnit4::class)
class TvScreenshotCapture {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

??

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This is a manual visual-regression tool (renders all dialog/bottom-sheet states to PNG). Not a unit test - excluded from CI. Will document in CONTRIBUTING.md or move to tools/.

- name: Locate build-tools
run: echo "BUILD_TOOLS=$(ls -d "$ANDROID_HOME"/build-tools/* | sort -V | tail -1)" >> "$GITHUB_ENV"

# --- Google TV quality criteria, enforced as build gates -------------------

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Why do we need to have build gates for TV quality criteria? Explain me

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Each gate enforces a Play Console TV requirement that would otherwise only be caught at submission:

Gate Checks Why it matters
Leanback manifest , , banner drawable Required for TV Play listing
16 KB page alignment ELF segment alignment Mandatory for Android 14+ ARM64
Both ABIs + TV devices ship both; missing one = not compatible

They're necessary because TV is a separate distribution track; catching at PR time avoids rejections. Happy to soften to warnings () if you prefer.

Comment on lines +71 to +79
app/build/outputs/apk/tv/release/app-tv-release-unsigned.apk > /dev/null

- name: TV-G6 - both 32-bit and 64-bit ABIs are shipped
run: |
abis=$(unzip -Z1 app/build/outputs/apk/tv/release/app-tv-release-unsigned.apk \
| grep -oE '^lib/[^/]+' | sort -u)
echo "$abis"
echo "$abis" | grep -q 'lib/arm64-v8a' # 64-bit
echo "$abis" | grep -q 'lib/armeabi-v7a' # 32-bit

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

What these each step doing? Are they necessary?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The two steps verify the release APK contains both and native libraries:

Google TV devices ship both ABIs; missing either marks the app "incompatible" on Play Console. Necessary because we ship native code (SQLite, etc.) — without both ABIs the APK won't install on 32-bit or 64-bit only devices.

@illnar-io

Copy link
Copy Markdown
Author

Screen recording attached: tv_demo.mp4 (1.6 MB, 1920×1080)

Shows:

  • Home screen D-pad navigation (toolbar → search → category chips → Browse button)
  • Search bar focus without auto-expand popup (ENTER expands)
  • Browse tab navigation (filter chips → torrent list)
  • Back navigation working

Note: On this AVD, DPAD_CENTER/ENTER key events don't trigger Compose onClick — only focus moves. The recording shows focus traversal; actual clicks work on physical TV hardware. Instrumented tests (FocusAuditTest) verify click behavior programmatically.

@markst

markst commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Nice - the portability analysis here lines up with the KMP/CMP scoping in #105.

The two Android-coupled pieces you flagged (NetworkClient's CookieManager Cloudflare path, and ConnectivityManager) are the same seams that migration needs to put behind interfaces.

No dependency between the efforts, but if the NetworkClient cookie/connectivity decoupling is done once in a platform-neutral way it'd serve both TV and multiplatform and avoid reworking the same file twice. Flagging for coordination.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants