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.
161 lines
8.1 KiB
Kotlin
161 lines
8.1 KiB
Kotlin
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 9–11);
|
||
// 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 ~5–9 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")
|
||
}
|