# android-webview-kiosk Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > > **REQUIRED SKILL:** Load the `lab` skill before starting — this is a lab project and the lab conventions are the source of truth for anything this plan doesn't cover. **Goal:** Android TV app that fullscreen-renders one hardcoded Grafana dashboard URL in a WebView, launchable remotely on a Sony Bravia via its REST IP-control API (which Control4 will drive). **Architecture:** Single-Activity Kotlin app, no androidx dependencies — `android.app.Activity` + `android.webkit.WebView` with JS enabled, immersive fullscreen, keep-screen-on, and exponential-backoff reload on load failure. `LEANBACK_LAUNCHER` intent filter makes it appear in the TV launcher and in the Bravia API's `getApplicationList`, so `setActiveApp` can launch it directly. Built with Gradle inside a nix dev shell (Android SDK via `androidenv`), distributed as a sideloaded APK. **Tech Stack:** Kotlin 2.0.21, Android Gradle Plugin 8.7.3, compileSdk/targetSdk 34, minSdk 26 (TV's final firmware is Android 8.0), JDK 17, Gradle from nixpkgs, JUnit 4 for unit tests. --- ## Background (why this exists) - TV: Sony Bravia KD-65XE9305, final firmware Android 8.0, **Android System WebView v138** (modern Chromium — renders current Grafana fine; verified on the TV). - The TV's built-in `webappruntime` is too old for Grafana, so a custom WebView app is the rendering vehicle. - Launch pipeline already proven with curl against the TV: `setPowerStatus` (wake) → `setActiveApp` (launch). Control4 driver will issue these; that driver is a **separate project, not in this plan**. This plan only documents the curl sequence the driver will replicate. - Dashboard URL (hardcoded in the app): `https://grafana.c3c.cz/public-dashboards/381fe3e71e164eb99dd0b10e246a36e2` ## Lab-convention deviations (intentional, pre-approved) This is an Android APK, not a Go service. The following lab defaults do not apply: - **No container image, no Docker Hub push, no helm chart, no `/livez`/`/readyz`.** The artifact is an APK uploaded by CI. - **No `nix build` of the app.** Gradle resolves Maven dependencies from the network, which pure nix builds can't do without heavyweight lockfile tooling. The flake provides a **dev shell only**; the build command is `gradle` inside `nix develop`. CI does the same. - **Go conventions (slog etc.) don't apply** — Kotlin/Android project. Everything else holds: git from commit 0, origin on Gitea, flake + `.envrc` at root, `.gitea/workflows/` CI, `CLAUDE.md` in repo. ## Execution constraints - **The agent cannot run `nix`, `gradle`, `adb`, or `curl`-against-the-TV in the jail.** Every build/test/install step below is phrased as "Ask the user to run X and report output." Do not fabricate results; wait for the user's paste. - The user runs nix commands per lab convention anyway. ## Repo location ``` /code/git.c3c.cz/c3c/android-webview-kiosk ``` `` is whichever of `/workspace/extras/lab` or `/mnt/storage/.lab` exists. All file paths below are relative to the repo root. Origin: `git@git.c3c.cz:c3c/android-webview-kiosk.git`. ## File structure ``` android-webview-kiosk/ ├── flake.nix # dev shell: Android SDK, JDK 17, gradle, imagemagick ├── flake.lock # committed after first `nix flake lock` ├── .envrc # exactly: use flake . ├── .gitignore ├── CLAUDE.md # repo context for future agents ├── README.md # sideload + Sony API + Control4 integration notes ├── .gitea/workflows/build.yaml ├── settings.gradle.kts ├── build.gradle.kts # root: plugin versions only ├── gradle.properties ├── signing/release.keystore # committed; see Task 6 rationale └── app/ ├── build.gradle.kts └── src/ ├── main/ │ ├── AndroidManifest.xml │ ├── java/cz/c3c/webviewkiosk/MainActivity.kt │ ├── java/cz/c3c/webviewkiosk/BackoffPolicy.kt │ └── res/drawable/{banner.png,icon.png} └── test/java/cz/c3c/webviewkiosk/BackoffPolicyTest.kt ``` Android package name is `cz.c3c.webviewkiosk` (hyphens are illegal in package names, so the repo name's `android-` prefix is dropped there). --- ### Task 1: Repo skeleton + git init **Files:** - Create: `.envrc`, `.gitignore` - [ ] **Step 1: Resolve lab root and verify target path is empty/missing** Run: `ls /workspace/extras/lab 2>/dev/null || ls /mnt/storage/.lab 2>/dev/null` to pick ``. Then verify `/code/git.c3c.cz/c3c/android-webview-kiosk` does not exist or is empty (lab bootstrap-mode rule). If it has content, **stop and ask the user**. - [ ] **Step 2: Create directories** ```bash mkdir -p /code/git.c3c.cz/c3c/android-webview-kiosk cd /code/git.c3c.cz/c3c/android-webview-kiosk mkdir -p app/src/main/java/cz/c3c/webviewkiosk \ app/src/main/res/drawable \ app/src/test/java/cz/c3c/webviewkiosk \ .gitea/workflows signing ``` - [ ] **Step 3: Write `.envrc`** ``` use flake . ``` - [ ] **Step 4: Write `.gitignore`** ``` .direnv/ .envrc.local # Gradle / Android .gradle/ build/ local.properties *.apk.idsig # Editor / OS .idea/ .vscode/ .DS_Store ``` Note: `build/` (no leading slash) intentionally matches both root and `app/build/`. `signing/release.keystore` is **not** ignored — it gets committed (Task 6). - [ ] **Step 5: Init git and wire origin** ```bash git init git remote add origin git@git.c3c.cz:c3c/android-webview-kiosk.git git add .envrc .gitignore git commit -m "chore: repo skeleton" ``` ### Task 2: Nix dev shell **Files:** - Create: `flake.nix` - [ ] **Step 1: Write `flake.nix`** ```nix { # android-webview-kiosk — Android TV fullscreen WebView kiosk showing a # hardcoded Grafana dashboard, remote-launched via Sony Bravia IP control. # # Deviation from lab Go template: this flake provides a DEV SHELL ONLY. # Gradle fetches Maven deps from the network, so the APK is built impurely: # nix develop --command gradle :app:assembleRelease # There is no `nix build` output and no container/push pipeline. description = "Android TV fullscreen WebView kiosk for the Grafana house dashboard"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/release-25.11"; flake-utils.url = "github:numtide/flake-utils"; }; outputs = { self, nixpkgs, flake-utils, }: flake-utils.lib.eachDefaultSystem ( system: let pkgs = import nixpkgs { inherit system; config = { allowUnfree = true; # Android SDK android_sdk.accept_license = true; }; }; buildToolsVersion = "34.0.0"; androidComposition = pkgs.androidenv.composeAndroidPackages { platformVersions = [ "34" ]; buildToolsVersions = [ buildToolsVersion ]; includeEmulator = false; includeSystemImages = false; includeNDK = false; }; androidSdk = androidComposition.androidsdk; sdkRoot = "${androidSdk}/libexec/android-sdk"; in { devShells.default = pkgs.mkShell { packages = [ androidSdk pkgs.jdk17 pkgs.gradle pkgs.imagemagick # banner/icon generation pkgs.android-tools # adb for sideloading ]; ANDROID_HOME = sdkRoot; ANDROID_SDK_ROOT = sdkRoot; # NixOS gotcha: AGP downloads a dynamically-linked aapt2 from Maven # that can't run on NixOS. Point it at the SDK's own aapt2 instead. GRADLE_OPTS = "-Dandroid.aapt2FromMavenOverride=${sdkRoot}/build-tools/${buildToolsVersion}/aapt2"; }; } ); } ``` - [ ] **Step 2: Ask user to run, paste output** ```bash nix flake lock nix develop --command bash -c 'java -version && gradle --version && echo "ANDROID_HOME=$ANDROID_HOME" && ls "$ANDROID_HOME/build-tools"' ``` Expected: JDK 17, Gradle 8.x, build-tools `34.0.0` listed. If `androidenv` rejects `platformVersions = [ "34" ]` (nixpkgs pin too new/old), ask the user for the error and adjust the version set — do not guess silently. - [ ] **Step 3: Commit** ```bash git add flake.nix flake.lock git commit -m "feat: nix dev shell with Android SDK 34, JDK 17, gradle" ``` ### Task 3: Gradle project files **Files:** - Create: `settings.gradle.kts`, `build.gradle.kts`, `gradle.properties`, `app/build.gradle.kts` - [ ] **Step 1: Write `settings.gradle.kts`** ```kotlin pluginManagement { repositories { google() mavenCentral() gradlePluginPortal() } } dependencyResolutionManagement { repositories { google() mavenCentral() } } rootProject.name = "android-webview-kiosk" include(":app") ``` - [ ] **Step 2: Write root `build.gradle.kts`** ```kotlin plugins { id("com.android.application") version "8.7.3" apply false id("org.jetbrains.kotlin.android") version "2.0.21" apply false } ``` - [ ] **Step 3: Write `gradle.properties`** ```properties org.gradle.jvmargs=-Xmx2g android.useAndroidX=true ``` - [ ] **Step 4: Write `app/build.gradle.kts`** ```kotlin plugins { id("com.android.application") id("org.jetbrains.kotlin.android") } android { namespace = "cz.c3c.webviewkiosk" compileSdk = 34 defaultConfig { applicationId = "cz.c3c.webviewkiosk" minSdk = 26 // Sony KD-65XE9305 final firmware = Android 8.0 targetSdk = 34 versionCode = 1 // bump on every release; adb install -r refuses downgrades versionName = "0.1.0" } signingConfigs { create("release") { // Keystore is committed (private repo, LAN kiosk app) so every // machine/CI produces the same signature and `adb install -r` // upgrades work without uninstalling. Created in Task 6. storeFile = rootProject.file("signing/release.keystore") storePassword = "android-webview-kiosk" keyAlias = "kiosk" keyPassword = "android-webview-kiosk" } } buildTypes { release { isMinifyEnabled = false signingConfig = signingConfigs.getByName("release") } } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { jvmTarget = "17" } } dependencies { testImplementation("junit:junit:4.13.2") } ``` - [ ] **Step 5: Ask user to run, paste output** ```bash nix develop --command gradle --no-daemon :app:tasks ``` Expected: task list prints, no configuration errors. Known failure mode: AGP 8.7.3 vs the nixpkgs Gradle version being too new — the error message names the supported range; bump the AGP version in root `build.gradle.kts` to the newest 8.x the message allows and re-run. - [ ] **Step 6: Commit** ```bash git add settings.gradle.kts build.gradle.kts gradle.properties app/build.gradle.kts git commit -m "feat: gradle project, AGP 8.7.3, sdk 34/min 26" ``` ### Task 4: BackoffPolicy (TDD) Reload-retry delay logic — the only pure logic in the app, so it gets a unit test. Exponential backoff stops a dead Grafana from being hammered every 2 s forever by a 24/7 TV. **Files:** - Test: `app/src/test/java/cz/c3c/webviewkiosk/BackoffPolicyTest.kt` - Create: `app/src/main/java/cz/c3c/webviewkiosk/BackoffPolicy.kt` - [ ] **Step 1: Write the failing test** ```kotlin package cz.c3c.webviewkiosk import org.junit.Assert.assertEquals import org.junit.Test class BackoffPolicyTest { @Test fun firstDelayIsInitial() { val b = BackoffPolicy(initialDelayMs = 2_000, maxDelayMs = 60_000) assertEquals(2_000, b.nextDelayMs()) } @Test fun delayDoublesOnEachCall() { val b = BackoffPolicy(initialDelayMs = 2_000, maxDelayMs = 60_000) b.nextDelayMs() assertEquals(4_000, b.nextDelayMs()) assertEquals(8_000, b.nextDelayMs()) } @Test fun delayCapsAtMax() { val b = BackoffPolicy(initialDelayMs = 2_000, maxDelayMs = 5_000) b.nextDelayMs() // 2000 b.nextDelayMs() // 4000 assertEquals(5_000, b.nextDelayMs()) assertEquals(5_000, b.nextDelayMs()) } @Test fun resetReturnsToInitial() { val b = BackoffPolicy(initialDelayMs = 2_000, maxDelayMs = 60_000) b.nextDelayMs() b.nextDelayMs() b.reset() assertEquals(2_000, b.nextDelayMs()) } } ``` - [ ] **Step 2: Ask user to run, verify it fails** ```bash nix develop --command gradle --no-daemon :app:testReleaseUnitTest ``` Expected: compilation FAILS with unresolved reference `BackoffPolicy`. - [ ] **Step 3: Write minimal implementation** ```kotlin package cz.c3c.webviewkiosk class BackoffPolicy( private val initialDelayMs: Long, private val maxDelayMs: Long, private val factor: Double = 2.0, ) { private var nextMs = initialDelayMs fun nextDelayMs(): Long { val current = nextMs nextMs = (nextMs * factor).toLong().coerceAtMost(maxDelayMs) return current } fun reset() { nextMs = initialDelayMs } } ``` - [ ] **Step 4: Ask user to run, verify it passes** ```bash nix develop --command gradle --no-daemon :app:testReleaseUnitTest ``` Expected: `BUILD SUCCESSFUL`, 4 tests passed. - [ ] **Step 5: Commit** ```bash git add app/src/test/java/cz/c3c/webviewkiosk/BackoffPolicyTest.kt \ app/src/main/java/cz/c3c/webviewkiosk/BackoffPolicy.kt git commit -m "feat: exponential backoff policy for webview reload" ``` ### Task 5: Manifest, resources, MainActivity No unit tests here — pure Android framework glue; verification is on-device in Task 7. **Files:** - Create: `app/src/main/AndroidManifest.xml` - Create: `app/src/main/res/drawable/banner.png`, `app/src/main/res/drawable/icon.png` - Create: `app/src/main/java/cz/c3c/webviewkiosk/MainActivity.kt` - [ ] **Step 1: Generate banner and icon** `android:banner` (320×180) is **mandatory** for `LEANBACK_LAUNCHER` apps — without it the TV launcher shows nothing usable. Ask user to run inside the dev shell: ```bash nix develop --command bash -c ' magick -size 320x180 xc:"#1f2430" -gravity center -fill white \ -pointsize 32 -annotate 0 "Grafana Kiosk" app/src/main/res/drawable/banner.png magick -size 160x160 xc:"#1f2430" -gravity center -fill "#f46800" \ -pointsize 90 -annotate 0 "G" app/src/main/res/drawable/icon.png ' ``` - [ ] **Step 2: Write `app/src/main/AndroidManifest.xml`** ```xml ``` `usesCleartextTraffic="true"`: dashboard is HTTPS today, but home-LAN dashboards are often plain HTTP — this keeps a future URL swap to `http://` working without a manifest change. Acceptable for a LAN kiosk device. - [ ] **Step 3: Write `app/src/main/java/cz/c3c/webviewkiosk/MainActivity.kt`** ```kotlin package cz.c3c.webviewkiosk import android.annotation.SuppressLint import android.app.Activity import android.os.Bundle import android.os.Handler import android.os.Looper import android.view.View import android.view.WindowManager import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient class MainActivity : Activity() { companion object { const val DASHBOARD_URL = "https://grafana.c3c.cz/public-dashboards/381fe3e71e164eb99dd0b10e246a36e2" } private lateinit var webView: WebView private val handler = Handler(Looper.getMainLooper()) private val backoff = BackoffPolicy(initialDelayMs = 2_000, maxDelayMs = 60_000) private var lastLoadFailed = false @SuppressLint("SetJavaScriptEnabled") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) webView = WebView(this) webView.settings.apply { javaScriptEnabled = true domStorageEnabled = true cacheMode = WebSettings.LOAD_DEFAULT mediaPlaybackRequiresUserGesture = false } webView.webViewClient = object : WebViewClient() { override fun onPageFinished(view: WebView, url: String) { // onPageFinished also fires after a failed load (error page), // so only treat a clean finish as recovery. if (!lastLoadFailed) backoff.reset() lastLoadFailed = false } override fun onReceivedError( view: WebView, request: WebResourceRequest, error: WebResourceError, ) { // Subresource failures (one panel's query, a font) must not // tear down the whole page; only main-frame failures retry. if (!request.isForMainFrame) return lastLoadFailed = true handler.postDelayed( { view.loadUrl(DASHBOARD_URL) }, backoff.nextDelayMs(), ) } } setContentView(webView) hideSystemUi() webView.loadUrl(DASHBOARD_URL) } override fun onResume() { super.onResume() hideSystemUi() } private fun hideSystemUi() { @Suppress("DEPRECATION") // WindowInsetsController needs API 30; minSdk is 26 window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY } override fun onDestroy() { handler.removeCallbacksAndMessages(null) webView.destroy() super.onDestroy() } } ``` - [ ] **Step 4: Ask user to run a debug build to verify it compiles** ```bash nix develop --command gradle --no-daemon :app:assembleDebug ``` Expected: `BUILD SUCCESSFUL`, APK at `app/build/outputs/apk/debug/app-debug.apk`. (Release build waits for the keystore in Task 6.) - [ ] **Step 5: Commit** ```bash git add app/src/main/AndroidManifest.xml app/src/main/res \ app/src/main/java/cz/c3c/webviewkiosk/MainActivity.kt git commit -m "feat: leanback webview kiosk activity" ``` ### Task 6: Release keystore + signed APK **Files:** - Create: `signing/release.keystore` - [ ] **Step 1: Ask user to generate the keystore** ```bash nix develop --command keytool -genkeypair \ -keystore signing/release.keystore \ -alias kiosk -keyalg RSA -keysize 2048 -validity 10000 \ -storepass android-webview-kiosk -keypass android-webview-kiosk \ -dname "CN=android-webview-kiosk" ``` Rationale for committing it with a plaintext password: the repo is private, the key signs a LAN-only kiosk APK (no Play Store, no trust anchored to it), and a stable committed key means CI and any machine produce upgrade-compatible signatures. Do not reuse this key for anything else. - [ ] **Step 2: Ask user to build + verify release APK** ```bash nix develop --command gradle --no-daemon :app:assembleRelease nix develop --command bash -c \ 'apksigner verify --print-certs app/build/outputs/apk/release/app-release.apk' ``` Expected: `BUILD SUCCESSFUL`; apksigner prints the `CN=android-webview-kiosk` cert. (`apksigner` lives in SDK build-tools, already on the dev-shell PATH.) - [ ] **Step 3: Commit** ```bash git add signing/release.keystore git commit -m "chore: committed release keystore for stable sideload signature" ``` ### Task 7: Sideload and on-TV verification No files — this is the acceptance test of Tasks 1–6. All commands run by the user. `$TV_IP` = the TV's LAN address, `$YOUR_PSK` = the IP-control pre-shared key already configured on the TV. - [ ] **Step 1: Enable ADB on the TV (one-time, user, on the TV)** Settings → About → press *Build* 7× (unlocks Developer options) → Developer options → enable **ADB debugging** / network debugging. - [ ] **Step 2: Ask user to install** ```bash nix develop --command bash -c " adb connect \$TV_IP:5555 && adb install -r app/build/outputs/apk/release/app-release.apk " ``` Expected: `Success`. If the TV shows an authorization dialog, accept it and re-run. Future upgrades: bump `versionCode` in `app/build.gradle.kts`, rebuild, same `adb install -r`. - [ ] **Step 3: Verify launcher + manual launch** "Grafana Kiosk" tile appears in the TV's app row; opening it shows the dashboard fullscreen. - [ ] **Step 4: Verify Sony API sees the app** ```bash curl -s -X POST http://$TV_IP/sony/appControl \ -H "X-Auth-PSK: $YOUR_PSK" -H 'Content-Type: application/json' \ -d '{"method":"getApplicationList","id":1,"params":[],"version":"1.0"}' \ | grep -o '"title":"Grafana Kiosk","uri":"[^"]*"' ``` Expected URI shape: `com.sony.dtv.cz.c3c.webviewkiosk.cz.c3c.webviewkiosk.MainActivity` — but **use whatever the list actually returns**; record the exact value in README.md (Task 8). - [ ] **Step 5: Verify the full remote sequence from standby** TV in standby, then: ```bash # wake curl -s -X POST http://$TV_IP/sony/system \ -H "X-Auth-PSK: $YOUR_PSK" -H 'Content-Type: application/json' \ -d '{"method":"setPowerStatus","id":55,"version":"1.0","params":[{"status":true}]}' sleep 6 # launch kiosk (URI from Step 4) curl -s -X POST http://$TV_IP/sony/appControl \ -H "X-Auth-PSK: $YOUR_PSK" -H 'Content-Type: application/json' \ -d '{"method":"setActiveApp","id":601,"version":"1.0","params":[{"uri":""}]}' ``` Expected: TV wakes, dashboard appears. Unlike `webappruntime`, a real installed app may wake the TV implicitly on `setActiveApp` alone — test that too and note the result in README; if it does, the Control4 driver gets simpler. ### Task 8: CI workflow + repo docs **Files:** - Create: `.gitea/workflows/build.yaml`, `CLAUDE.md`, `README.md` - [ ] **Step 1: Write `.gitea/workflows/build.yaml`** Adapted from the lab Go template: same trigger/concurrency/runner-image shape, but tests + APK artifact instead of container push, and no `bump-chart` job (no chart). ```yaml name: build on: push: branches: [main] tags: ['*'] concurrency: group: ${{ github.ref }} cancel-in-progress: true jobs: build: runs-on: ubuntu-latest container: image: docksee/nixos-gitea:${{ vars.NIX_IMAGE_VERSION }} steps: - uses: actions/checkout@v5 with: fetch-depth: 0 - name: Unit tests run: nix develop --command gradle --no-daemon :app:testReleaseUnitTest - name: Build release APK run: nix develop --command gradle --no-daemon :app:assembleRelease - uses: actions/upload-artifact@v3 with: name: android-webview-kiosk-apk path: app/build/outputs/apk/release/app-release.apk ``` Known cost: Gradle re-downloads Maven deps every run (no cache step). Acceptable for a rarely-changing project; add a cache later only if it hurts. - [ ] **Step 2: Write `CLAUDE.md`** ```markdown # android-webview-kiosk Android TV app: fullscreen WebView showing one hardcoded Grafana dashboard. Runs on the living-room Sony Bravia KD-65XE9305 (Android 8.0, API 26; Android System WebView updates independently via Play Store — currently v138). Launched remotely through the Bravia REST IP-control API by Control4 (driver lives in a separate project). See README.md for the API sequence. ## Lab project — with deviations This is a lab project (`lab` skill conventions apply) EXCEPT: - No container image, no helm chart, no /livez//readyz — artifact is an APK. - No `nix build`: gradle needs network for Maven deps. The flake is a dev shell only. All builds run as `nix develop --command gradle ...`. - Agents cannot run nix/gradle/adb here — ask the user to run commands and report output. ## Commands (user-run, from repo root) - Tests: `nix develop --command gradle --no-daemon :app:testReleaseUnitTest` - Release APK: `nix develop --command gradle --no-daemon :app:assembleRelease` → `app/build/outputs/apk/release/app-release.apk` - Sideload: `adb connect :5555 && adb install -r ` (bump `versionCode` in app/build.gradle.kts first for upgrades) ## Key facts - Package/applicationId: `cz.c3c.webviewkiosk`; repo name keeps the `android-` prefix, the package can't (hyphens illegal). - Dashboard URL hardcoded in `MainActivity.DASHBOARD_URL` — changing it means rebuild + sideload. Intentional: keeps Control4 integration to a single parameterless launch call. - `signing/release.keystore` is committed on purpose (private repo, LAN-only kiosk) so every build is upgrade-compatible. Don't reuse the key. - minSdk 26 is a hard floor — the TV never gets newer Android. - `LEANBACK_LAUNCHER` category + `android:banner` are what make the app visible to the Bravia API's `getApplicationList`/`setActiveApp`. Don't remove either. ``` - [ ] **Step 3: Write `README.md`** ```markdown # android-webview-kiosk Fullscreen WebView kiosk for Android TV. Shows the house Grafana dashboard: Target device: Sony Bravia KD-65XE9305 (Android 8.0). Built for remote launch via the Bravia REST IP-control API, driven by Control4. ## Build nix develop --command gradle --no-daemon :app:assembleRelease APK: `app/build/outputs/apk/release/app-release.apk` ## Sideload 1. TV one-time: Settings → About → press *Build* 7× → Developer options → enable ADB debugging. 2. `adb connect :5555` 3. `adb install -r app/build/outputs/apk/release/app-release.apk` Upgrades: bump `versionCode` in `app/build.gradle.kts`, rebuild, reinstall with `-r`. Same committed keystore = no uninstall needed. ## Remote launch (Sony Bravia IP control) TV prerequisite: IP control auth = "Normal and Pre-Shared Key", Remote start enabled (wake from deep standby). # 1. wake curl -s -X POST http://$TV_IP/sony/system \ -H "X-Auth-PSK: $PSK" -H 'Content-Type: application/json' \ -d '{"method":"setPowerStatus","id":55,"version":"1.0","params":[{"status":true}]}' # 2. launch (after TV reports active; ~6 s from quick standby, # up to ~30 s from deep eco standby — poll getPowerStatus instead # of sleeping blind in real integrations) curl -s -X POST http://$TV_IP/sony/appControl \ -H "X-Auth-PSK: $PSK" -H 'Content-Type: application/json' \ -d '{"method":"setActiveApp","id":601,"version":"1.0","params":[{"uri":""}]}' ``: from `getApplicationList`, recorded after first install: (fill in after Task 7 Step 4 — expected shape: com.sony.dtv.cz.c3c.webviewkiosk.cz.c3c.webviewkiosk.MainActivity) Whether `setActiveApp` alone wakes the TV from standby (skipping step 1): (fill in after Task 7 Step 5) Control4: a DriverWorks driver issuing this sequence is a separate project. ``` The two "(fill in after Task 7 …)" lines are the only deliberate blanks — they record device-measured facts and must be filled during Task 7, not invented. - [ ] **Step 4: Commit** ```bash git add .gitea/workflows/build.yaml CLAUDE.md README.md git commit -m "feat: gitea CI (test + apk artifact), repo docs" ``` ### Task 9: Handoff checklist (user actions) Print this for the user at the end; none of it is automatable from the jail: 1. Create the Gitea repo `c3c/android-webview-kiosk` (no chart repo, no `chart-service-account` collaborator needed). 2. `git push -u origin main` — CI should run tests, build the APK, upload the artifact. Check the Actions run. 3. Fill the two README blanks with values measured in Task 7 (app URI, wake behavior), commit, push. 4. Follow-up project (separate plan, separate session): Control4 DriverWorks driver — one Composer command "Show Dashboard" → `setPowerStatus` → poll `getPowerStatus` until `active` → `setActiveApp` with the recorded URI. Load the `control4-driverworks` skill when planning it. --- ## Self-review notes - Spec coverage: hardcoded-URL WebView app ✓, leanback launchability for `setActiveApp` ✓, fullscreen/keep-awake/error-recovery ✓, lab conventions (flake, envrc, gitea CI, CLAUDE.md, git from commit 0) ✓, sideload + remote-launch verification ✓. Control4 driver explicitly out of scope. - Versions (AGP 8.7.3 / Kotlin 2.0.21 / Gradle-from-nixpkgs) are a known-good family but the nixpkgs pin moves; Tasks 2–3 contain explicit verify-and-adjust steps instead of assuming. - Type consistency: `BackoffPolicy(initialDelayMs, maxDelayMs)`, `nextDelayMs()`, `reset()` match between Task 4 test, Task 4 impl, and Task 5 usage.