feat: nix dev shell with Android SDK 34, JDK 17, gradle
This commit is contained in:
@@ -0,0 +1,850 @@
|
||||
# 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 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":"<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 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.
|
||||
Reference in New Issue
Block a user