Files
android-webview-kiosk/PLAN.md
T
2026-06-12 00:28:27 +02:00

851 lines
30 KiB
Markdown
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.
# 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
```
<lab>/code/git.c3c.cz/c3c/android-webview-kiosk
```
`<lab>` 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 `<lab>`. Then verify `<lab>/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 <lab>/code/git.c3c.cz/c3c/android-webview-kiosk
cd <lab>/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
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- TV-only app: leanback required, touchscreen not -->
<uses-feature
android:name="android.software.leanback"
android:required="true" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<application
android:allowBackup="false"
android:banner="@drawable/banner"
android:icon="@drawable/icon"
android:label="Grafana Kiosk"
android:usesCleartextTraffic="true"
android:theme="@android:style/Theme.NoTitleBar.Fullscreen">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="landscape"
android:configChanges="keyboard|keyboardHidden|navigation|screenSize|density">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
```
`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 16. 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":"<URI-FROM-STEP-4>"}]}'
```
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 <tv-ip>:5555 && adb install -r <apk>`
(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:
<https://grafana.c3c.cz/public-dashboards/381fe3e71e164eb99dd0b10e246a36e2>
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 <tv-ip>: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":"<APP_URI>"}]}'
`<APP_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 23 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.