Files
punktfunk/clients/android/app/build.gradle.kts
T
enricobuehler 0b550dad84 feat(android): the console tables stop drifting in silence, and the stats overlay gets a pad route
WP8 and WP9 of `punktfunk-planning/design/android-console-ui-visual-refresh.md`, in part.

**WP9.3 — shared parity vectors.** The console's background palettes, its settings section names
and its screen-transition motion each existed in three hand-written copies (`pf-console-ui`, this
client, the Apple client) held together by a comment asking the next person to keep them in step.
`clients/shared/console-vectors.json` now holds them, read the way `deeplink-vectors.json` already
is: `include_str!` in Rust, a relative path in Kotlin, `#filePath` in Swift — never a copy, because
a copy is a fourth contract free to go stale. It carries the DERIVED tables too, the 16-cell mesh
and the 4 blob colours per palette, which is the half that reaches the screen and the half Android
never checked: `GamepadPaletteTest` only ever measured the `stops` they are computed from.

Two drifts it immediately caught, both now closed:

* **The easing was the wrong curve.** `ConsoleMotion.EaseOutCubic` shipped as
  `cubic-bezier(0.215, 0.61, 0.355, 1)` while claiming to be the desktop's `ease_out_cubic`. It is
  not: that is the Penner/Ceaser table's curve, ~0.80 at the midpoint where `1 − (1−t)³` is 0.875 —
  visibly slacker over a 260 ms transition. Compose's `Easing` is a plain function, so it now
  evaluates the real thing analytically rather than approximating it at all. (Apple approximates
  with a different bezier only because SwiftUI's `timingCurve` cannot take a closure; the vectors
  sample the curve with a tolerance so all three can meet it.)
* **The desktop has a seventh tab.** Input — touch mode, mouse, invert-scroll, shortcuts — with
  nothing to set on a phone or a TV. `settings.rs` claims in prose that a setting is found under
  the same word on every client; that was true modulo an omission nobody could see. The vectors
  model it with `desktop_only` rather than picking a side, so neither client has to be wrong.

Rust reads it from three tests placed in the files that own the constants, so nothing had to be
made `pub` to be checkable. Verified green under Linux (the crate is `cfg(linux|windows)` throughout
— `cargo test` on a Mac compiles nothing and passes vacuously): 77 passed, 0 failed. Android's side
gates in CI as a FILTERED task; a plain `:app:testDebugUnitTest` would drag the ~20 Roborazzi
screenshot scenes into every push, and those are a release-artifact job.

**WP8.1 — a pad route to the stats overlay.** The tier could only be cycled by a three-finger tap,
which does not exist on a TV, on a gamepad-only session, or under touch passthrough — while the
settings row promised a live cycle. `Select + X` now cycles it, byte-identical to the Apple
client's `GamepadWire.back | GamepadWire.x`, implemented as the mic chord's twin in `GamepadRouter`
and edge-triggered on the button that completes the mask. The buttons still reach the game, as both
existing chords do. `GamepadChordTest` pins eight cases the kit had no cover for at all, including
that the three chords intersect only on Select and that none is reachable through another.

**WP8.5 — a start-of-stream banner.** The desktop's `skia_overlay` banner, ported with its timing
(opaque 5.4 s, then a 0.6 s fade) and its rule of naming only shortcuts that exist: pad chords when
a pad is present, the touch gesture when there is a touchscreen and the mode can use it. Nothing
`Ctrl+Alt+Shift` is advertised, because Android has none of it. It yields to the motion-unreachable
notice rather than stacking with it — that one reports something broken about *this* session.

**WP8.6 — the home card says which profile it connects with.** `HomeTile` carried a
`pinnedProfileId` the card never drew, so a pinned host+profile card was distinguishable from the
host's own only by a subtitle that had been quietly repurposed to hold the profile name. Both now
show the address like every other card and wear a tinted profile chip — the touch grid's own
convention and the Apple client's, inked from the console palette. Unsaved tiles (discovered, Add
Host) take a dashed edge, which is what the other two surfaces already use to say "not yours yet".

⚠ Not a detail panel: the Apple client REMOVED its own and moved the status onto the card, which is
where the lock and the online pip already were here.

**WP8.7 — accessibility, in part.** The console screens carried three `contentDescription`s and no
`semantics`, `Role` or `stateDescription` at all. A settings row now announces once, merged —
label, value, and the description that lives in the floating band far from it — with `Role.Switch`
and a real toggle state, because a toggle row's on/off string was drawn by nothing at all: the
switch replaces the value text, and the switch was two undescribed `Box`es. Decoration is silenced
rather than labelled (the chevrons were read aloud as punctuation on every focused row). The hint
bar's glyphs, the tab strip and the home tiles are done; `GamepadAddHostScreen` and `LibraryScreen`
are not yet.
2026-08-10 19:20:40 +02:00

161 lines
8.1 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.util.Properties
plugins {
id("com.android.application")
// AGP 9 built-in Kotlin: NO org.jetbrains.kotlin.android. The Compose compiler plugin is
// supplied by AGP, so it's applied without a version.
id("org.jetbrains.kotlin.plugin.compose")
}
android {
namespace = "io.unom.punktfunk"
compileSdk = 37 // Android 17 — required by androidx.core 1.19.0.
defaultConfig {
// Load from .env if it exists (local dev), otherwise from System.getenv (CI)
val envFile = project.rootProject.file(".env")
val props = Properties()
if (envFile.exists()) {
envFile.inputStream().use { props.load(it) }
}
applicationId = "io.unom.punktfunk"
// Android 9. Reaches older Android TV boxes (e.g. Amlogic streamers still on Android 911);
// the handful of API 31+ APIs we use are runtime-gated (Material You → brand palette, rumble
// → legacy Vibrator, NEARBY_WIFI/lights/ADPF already gated), so nothing is lost above 28.
minSdk = 28
// Android 17: targeting 37 makes Local Network Protection MANDATORY — all LAN traffic (the
// QUIC dial, mDNS, WoL, the library fetch) is blocked until the user grants the
// ACCESS_LOCAL_NETWORK runtime permission. ConnectScreen owns that request/rationale flow;
// don't bump past 37 without re-checking the next release's behavior changes.
targetSdk = 37
val vCode = (props.getProperty("VERSION_CODE") ?: System.getenv("VERSION_CODE"))
versionCode = vCode?.toInt() ?: 1
// versionName is the single project version, threaded from CI (a vX.Y.Z release or a
// canary string). versionCode stays the monotonic run number (Play rejects regressions).
// Local dev (no VERSION_NAME) falls back to the workspace version from the root Cargo.toml —
// the single source of truth — so an on-device build shows the real current version, not a
// stale placeholder.
val workspaceVersion = runCatching {
project.rootProject.file("../../Cargo.toml").readLines()
.dropWhile { !it.trim().startsWith("[workspace.package]") }
.firstOrNull { it.trim().startsWith("version") }
?.substringAfter('=')?.trim()?.trim('"')
}.getOrNull()
versionName = (props.getProperty("VERSION_NAME") ?: System.getenv("VERSION_NAME"))
?: workspaceVersion ?: "0.0.0"
// Ship 32-bit armeabi-v7a alongside 64-bit arm64-v8a: many Google TV / Android TV streamers
// (Walmart onn. 4K, Chromecast with Google TV, budget Amlogic boxes) run a 32-bit Android
// userspace, and because this app carries native code, Google Play (and a sideload installer)
// filters it as "not compatible" on those devices unless an armeabi-v7a variant is present.
// x86_64 stays for the emulator. Google keeps delivering to 32-bit TV devices (see the Aug
// 2025 "64-bit app compatibility for Google TV and Android TV" post) — the 64-bit lib is the
// required half; the 32-bit lib is what actually reaches the boxes people report failing.
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") }
}
signingConfigs {
create("release") {
// Load from .env if it exists (local dev), otherwise from System.getenv (CI)
val envFile = project.rootProject.file(".env")
val props = Properties()
if (envFile.exists()) {
envFile.inputStream().use { props.load(it) }
}
val ksFile = props.getProperty("RELEASE_KEYSTORE_FILE") ?: System.getenv("RELEASE_KEYSTORE_FILE")
if (ksFile != null) {
storeFile = file(ksFile)
storePassword = props.getProperty("RELEASE_KEYSTORE_PASSWORD") ?: System.getenv("RELEASE_KEYSTORE_PASSWORD")
keyAlias = props.getProperty("RELEASE_KEY_ALIAS") ?: System.getenv("RELEASE_KEY_ALIAS")
keyPassword = props.getProperty("RELEASE_KEY_PASSWORD") ?: System.getenv("RELEASE_KEY_PASSWORD")
}
}
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
signingConfig = signingConfigs.getByName("release")
}
}
buildFeatures { compose = true }
// Roborazzi/Robolectric render Compose on the host JVM (the CI screenshot harness) and need the
// merged Android resources + the app's manifest/theme available to the unit tests.
testOptions { unitTests { isIncludeAndroidResources = true } }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
packaging {
jniLibs {
useLegacyPackaging = false
// punktfunk-core is statically linked into libpunktfunk_android.so (rlib). Its standalone
// cdylib (built because the core crate also declares crate-type = cdylib) is never loaded
// by Kotlin — drop it from the APK rather than ship ~59 MB of dead code.
excludes += "**/libpunktfunk_core.so"
}
}
}
kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } }
dependencies {
implementation(project(":kit"))
val composeBom = platform("androidx.compose:compose-bom:2026.05.01")
implementation(composeBom)
implementation("androidx.core:core-ktx:1.19.0")
implementation("androidx.activity:activity-compose:1.13.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-core") // bottom-bar / rail tab icons
implementation("androidx.compose.material:material-icons-extended") // settings-category icons
debugImplementation("androidx.compose.ui:ui-tooling")
// Cover-art loading for the game-library coverflow. Coil 2.x uses OkHttp under the hood, so we
// feed it the same mTLS OkHttpClient the library fetch uses (reaching the host's own art proxy).
implementation("io.coil-kt:coil-compose:2.7.0")
// Real backdrop blur for the floating console legends (RenderEffect on API 31+, a translucent
// scrim below). The gamepad UI's frosted pills sample + blur whatever scrolls behind them.
implementation("dev.chrisbanes.haze:haze:1.6.0")
// Android TV components (we target phone + TV) land in the TV-UI milestone:
// implementation("androidx.tv:tv-material:1.1.0")
// The manifest already declares leanback so the scaffold installs on TV.
// --- CI screenshot harness (Roborazzi on the JVM via Robolectric — no emulator/GPU). The
// screenshot tests render the real Compose UI with mock state; never load the JNI core, so the
// job runs `:app:testDebugUnitTest -PskipRustBuild` (see kit/build.gradle.kts). ---
testImplementation(composeBom)
testImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-test-manifest") // the ComponentActivity test host
testImplementation("junit:junit:4.13.2")
// Real `org.json` for the shared-vectors test: the `org.json` inside `android.jar` is a stub
// set whose every method throws "Stub!", so a plain JVM unit test cannot parse with it. Same
// dependency, same reason, as the kit module's deeplink-vectors test.
testImplementation("org.json:json:20250107")
testImplementation("org.robolectric:robolectric:4.16.1")
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.64.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.64.0")
}
// Record (write) the screenshots when the unit tests run. These tests exist to GENERATE marketing
// images, not to diff goldens, so always capture rather than verify.
tasks.withType<Test>().configureEach {
systemProperty("roborazzi.test.record", "true")
}