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

30 KiB
Raw Blame History

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
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
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

{
  # 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
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
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

pluginManagement {
    repositories {
        google()
        mavenCentral()
        gradlePluginPortal()
    }
}

dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
    }
}

rootProject.name = "android-webview-kiosk"
include(":app")
  • Step 2: Write root build.gradle.kts
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
org.gradle.jvmargs=-Xmx2g
android.useAndroidX=true
  • Step 4: Write app/build.gradle.kts
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
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
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

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
nix develop --command gradle --no-daemon :app:testReleaseUnitTest

Expected: compilation FAILS with unresolved reference BackoffPolicy.

  • Step 3: Write minimal implementation
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
nix develop --command gradle --no-daemon :app:testReleaseUnitTest

Expected: BUILD SUCCESSFUL, 4 tests passed.

  • Step 5: Commit
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:

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 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
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
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
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

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
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
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
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
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:

# 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).

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
# 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
# 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
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 activesetActiveApp 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.