diff --git a/.superpowers/brainstorm/1131-1781214773/content/design-section1.html b/.superpowers/brainstorm/1131-1781214773/content/design-section1.html new file mode 100644 index 0000000..d51ee14 --- /dev/null +++ b/.superpowers/brainstorm/1131-1781214773/content/design-section1.html @@ -0,0 +1,98 @@ +

Design — Cross-section (top view)

+

How the piece sits in the corner. All measurements approximate — confirm before generating.

+ +
+
Top-down cross section at any height
+
+ + + + + + wall + wall + + + + + flange + + + + + + cable + + + + spine + + + + + + ribbon blade (10cm wide) + + + + + ~8cm from corner axis + + + + spine legs ~20mm + + + + 4mm thick + + + + 45° diagonal + + + + + +
+
+ +
+

Key dimensions

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Piece height250mm
Total stack4 pieces × 250mm = 1000mm
Ribbon width100mm
Ribbon thickness4mm
Protrusion from corner~80mm (tip of ribbon at max extent)
Twist per piece360° — ribbon returns to same angle at top/bottom
Spine triangle legs~20mm × 20mm (right isosceles, sits in 90° corner)
Cable channel~8mm circular hole through centre of spine
+
+ +

Does this cross-section look right? Let me know in terminal.

diff --git a/.superpowers/brainstorm/1131-1781214773/state/server-info b/.superpowers/brainstorm/1131-1781214773/state/server-info new file mode 100644 index 0000000..59f0058 --- /dev/null +++ b/.superpowers/brainstorm/1131-1781214773/state/server-info @@ -0,0 +1 @@ +{"type":"server-started","port":52609,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:52609","screen_dir":"/home/becky/.claude-jail/.superpowers/brainstorm/1131-1781214773/content","state_dir":"/home/becky/.claude-jail/.superpowers/brainstorm/1131-1781214773/state"} diff --git a/.superpowers/brainstorm/1131-1781214773/state/server.log b/.superpowers/brainstorm/1131-1781214773/state/server.log new file mode 100644 index 0000000..1319ffe --- /dev/null +++ b/.superpowers/brainstorm/1131-1781214773/state/server.log @@ -0,0 +1,2 @@ +{"type":"server-started","port":52609,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:52609","screen_dir":"/home/becky/.claude-jail/.superpowers/brainstorm/1131-1781214773/content","state_dir":"/home/becky/.claude-jail/.superpowers/brainstorm/1131-1781214773/state"} +{"type":"screen-added","file":"/home/becky/.claude-jail/.superpowers/brainstorm/1131-1781214773/content/design-section1.html"} diff --git a/.superpowers/brainstorm/1131-1781214773/state/server.pid b/.superpowers/brainstorm/1131-1781214773/state/server.pid new file mode 100644 index 0000000..43cff8a --- /dev/null +++ b/.superpowers/brainstorm/1131-1781214773/state/server.pid @@ -0,0 +1 @@ +1139 diff --git a/.superpowers/brainstorm/421-1781209654/content/cross-section.html b/.superpowers/brainstorm/421-1781209654/content/cross-section.html new file mode 100644 index 0000000..919f74c --- /dev/null +++ b/.superpowers/brainstorm/421-1781209654/content/cross-section.html @@ -0,0 +1,89 @@ +

Cross-section of the twisted spine

+

The shape that rotates as it rises — shown end-on, then how twist looks from front

+ +
+ +
+
+ + + end-on + + + + + front + + + + +
+
+

Flat Ribbon / Blade

+

Thin wide strip — like a Möbius blade. Very dramatic from the side (wide face) but nearly disappears when viewed edge-on. Strong light/shadow contrast.

+
+
+ +
+
+ + end-on + + + + front + + + + +
+
+

Round Rod / Cylinder

+

Circular cross-section spirals around corner axis. Twist is subtle — the silhouette curves gently. Smooth and elegant, easiest to print.

+
+
+ +
+
+ + end-on + + + + front + + + + + + +
+
+

Star / Multi-Point

+

3–5 pointed star cross-section. As it twists the spikes create a dynamic rippling silhouette. Most visually complex, high detail on print.

+
+
+ +
+
+ + end-on + + + + front + + + + + + + +
+
+

Triangle / Prism

+

3-sided prism twisting. Faces alternate between facing you and turning away — creates a strong angular rhythm as you walk past.

+
+
+ +
diff --git a/.superpowers/brainstorm/421-1781209654/content/design-section1.html b/.superpowers/brainstorm/421-1781209654/content/design-section1.html new file mode 100644 index 0000000..d51ee14 --- /dev/null +++ b/.superpowers/brainstorm/421-1781209654/content/design-section1.html @@ -0,0 +1,98 @@ +

Design — Cross-section (top view)

+

How the piece sits in the corner. All measurements approximate — confirm before generating.

+ +
+
Top-down cross section at any height
+
+ + + + + + wall + wall + + + + + flange + + + + + + cable + + + + spine + + + + + + ribbon blade (10cm wide) + + + + + ~8cm from corner axis + + + + spine legs ~20mm + + + + 4mm thick + + + + 45° diagonal + + + + + +
+
+ +
+

Key dimensions

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Piece height250mm
Total stack4 pieces × 250mm = 1000mm
Ribbon width100mm
Ribbon thickness4mm
Protrusion from corner~80mm (tip of ribbon at max extent)
Twist per piece360° — ribbon returns to same angle at top/bottom
Spine triangle legs~20mm × 20mm (right isosceles, sits in 90° corner)
Cable channel~8mm circular hole through centre of spine
+
+ +

Does this cross-section look right? Let me know in terminal.

diff --git a/.superpowers/brainstorm/421-1781209654/content/style-v2.html b/.superpowers/brainstorm/421-1781209654/content/style-v2.html new file mode 100644 index 0000000..8c0d228 --- /dev/null +++ b/.superpowers/brainstorm/421-1781209654/content/style-v2.html @@ -0,0 +1,122 @@ +

More abstract — protrudes into room space

+

Pieces extend outward from corner (x+y axis), not just flat trim

+ +
+ +
+
+ + + top view → + + + + + + + + + + + +
+
+

Twisted Spine

+

Ribbon or helix that spirals out from corner as it rises. Each 25cm piece is one full twist. Dramatic shadow play.

+
+
+ +
+
+ + top view → + + + + + + + + + + + + +
+
+

Wave / Oscillation

+

Alternating peaks that swing out along x-axis then y-axis as you go up. Smooth sinusoidal form, organic feel despite being parametric.

+
+
+ +
+
+ + top view → + + + + + + + + + + + + + + + + + + → x + ↓ y + +
+
+

Rotating Fins

+

Horizontal blades that rotate 90° with each piece — alternating x-axis and y-axis protrusion. Strong geometric rhythm, industrial-abstract feel.

+
+
+ +
+
+ + front view → + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+

Branching / Fractal

+

Tree-like structure grows from corner outward. Each piece has a self-similar branching pattern at different scale. Bold, sculptural.

+
+
+ +
diff --git a/.superpowers/brainstorm/421-1781209654/content/style.html b/.superpowers/brainstorm/421-1781209654/content/style.html new file mode 100644 index 0000000..669d8d9 --- /dev/null +++ b/.superpowers/brainstorm/421-1781209654/content/style.html @@ -0,0 +1,86 @@ +

What style for the corner decoration?

+

1 meter tall corner trim, wall-to-wall 90° corner, split into 4 × 25cm pieces

+ +
+ +
+
+ + + + + + + + + + + + +
+
+

Geometric / Modern

+

Clean angular lines, diamond or triangular facets, sharp repeating pattern. Looks great in contemporary interiors.

+
+
+ +
+
+ + + + + + + + + +
+
+

Classical / Ornate

+

Curved profiles, egg-and-dart or bead motifs, traditional molding style. Suits older or formal interiors.

+
+
+ +
+
+ + + + + + + + + + + + + + +
+
+

Nature / Organic

+

Flowing vines, leaves, or botanical motifs. Soft curves, asymmetric detail. Pairs with eclectic or boho spaces.

+
+
+ +
+
+ + + + + + + + + +
+
+

Minimalist

+

Smooth rounded bead or simple stepped profile, maybe subtle groove accents. Blends into almost any interior.

+
+
+ +
diff --git a/.superpowers/brainstorm/421-1781209654/content/twist-depth.html b/.superpowers/brainstorm/421-1781209654/content/twist-depth.html new file mode 100644 index 0000000..35b1128 --- /dev/null +++ b/.superpowers/brainstorm/421-1781209654/content/twist-depth.html @@ -0,0 +1,82 @@ +

Twist rate + protrusion depth

+

Per 25cm piece — front view. Ribbon is ~5cm wide, ~4mm thick. Corner is the left edge.

+ +
+ +
+
+ + + + + 180° / 4cm out + + + + + + + 4cm + +
+
+

Gentle — 180° / 4cm

+

One half-turn per piece. Subtle reach. Elegant, understated. Good if it sits close to the corner.

+
+
+ +
+
+ + + 360° / 8cm out + + + + + 8cm + +
+
+

Balanced — 360° / 8cm

+

One full turn per piece. Confident reach into the room. You clearly see the twist from any angle.

+
+
+ +
+
+ + + 360° / 14cm out + + + + + 14cm + +
+
+

Bold — 360° / 14cm

+

One full turn, reaches deep into room. Real sculptural presence. Dominant in the space.

+
+
+ +
+
+ + + 720° / 14cm out + + + + + 14cm + +
+
+

Intense — 720° / 14cm

+

Two full turns per piece. Rapid tight twisting with bold reach. Very dynamic — almost turbulent energy.

+
+
+ +
diff --git a/.superpowers/brainstorm/421-1781209654/content/waiting.html b/.superpowers/brainstorm/421-1781209654/content/waiting.html new file mode 100644 index 0000000..ef07652 --- /dev/null +++ b/.superpowers/brainstorm/421-1781209654/content/waiting.html @@ -0,0 +1,3 @@ +
+

Continuing in terminal...

+
diff --git a/.superpowers/brainstorm/421-1781209654/state/server-stopped b/.superpowers/brainstorm/421-1781209654/state/server-stopped new file mode 100644 index 0000000..f0d9aac --- /dev/null +++ b/.superpowers/brainstorm/421-1781209654/state/server-stopped @@ -0,0 +1 @@ +{"reason":"idle timeout","timestamp":1781212895650} diff --git a/.superpowers/brainstorm/421-1781209654/state/server.log b/.superpowers/brainstorm/421-1781209654/state/server.log new file mode 100644 index 0000000..e3f6d0a --- /dev/null +++ b/.superpowers/brainstorm/421-1781209654/state/server.log @@ -0,0 +1,12 @@ +{"type":"server-started","port":49435,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:49435","screen_dir":"/home/becky/.claude-jail/.superpowers/brainstorm/421-1781209654/content","state_dir":"/home/becky/.claude-jail/.superpowers/brainstorm/421-1781209654/state"} +{"type":"screen-added","file":"/home/becky/.claude-jail/.superpowers/brainstorm/421-1781209654/content/style.html"} +{"type":"screen-added","file":"/home/becky/.claude-jail/.superpowers/brainstorm/421-1781209654/content/style-v2.html"} +{"source":"user-event","type":"click","text":"top view →\n \n \n \n \n \n \n \n \n \n \n \n \n \n Twisted Spine\n Ribbon or helix that spirals out from corner as it rises. Each 25cm piece is one full twist. Dramatic shadow play.","choice":"twist","id":null,"timestamp":1781210786563} +{"source":"user-event","type":"click","text":"top view →\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n Wave / Oscillation\n Alternating peaks that swing out along x-axis then y-axis as you go up. Smooth sinusoidal form, organic feel despite being parametric.","choice":"wave","id":null,"timestamp":1781210796933} +{"source":"user-event","type":"click","text":"top view →\n \n \n \n \n \n \n \n \n \n \n \n \n \n Twisted Spine\n Ribbon or helix that spirals out from corner as it rises. Each 25cm piece is one full twist. Dramatic shadow play.","choice":"twist","id":null,"timestamp":1781210797729} +{"type":"screen-added","file":"/home/becky/.claude-jail/.superpowers/brainstorm/421-1781209654/content/cross-section.html"} +{"source":"user-event","type":"click","text":"end-on\n \n \n \n \n front\n \n \n \n \n \n \n Flat Ribbon / Blade\n Thin wide strip — like a Möbius blade. Very dramatic from the side (wide face) but nearly disappears when viewed edge-on. Strong light/shadow contrast.","choice":"ribbon","id":null,"timestamp":1781210944918} +{"type":"screen-added","file":"/home/becky/.claude-jail/.superpowers/brainstorm/421-1781209654/content/twist-depth.html"} +{"source":"user-event","type":"click","text":"360° / 8cm out\n \n \n \n \n 8cm\n \n \n \n Balanced — 360° / 8cm\n One full turn per piece. Confident reach into the room. You clearly see the twist from any angle.","choice":"full-medium","id":null,"timestamp":1781211055275} +{"type":"screen-added","file":"/home/becky/.claude-jail/.superpowers/brainstorm/421-1781209654/content/waiting.html"} +{"type":"server-stopped","reason":"idle timeout"} diff --git a/.superpowers/brainstorm/421-1781209654/state/server.pid b/.superpowers/brainstorm/421-1781209654/state/server.pid new file mode 100644 index 0000000..2b20fd0 --- /dev/null +++ b/.superpowers/brainstorm/421-1781209654/state/server.pid @@ -0,0 +1 @@ +449 diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..d49bc9c --- /dev/null +++ b/PLAN.md @@ -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 + +``` +/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. diff --git a/docs/superpowers/specs/2026-06-11-corner-decoration-design.md b/docs/superpowers/specs/2026-06-11-corner-decoration-design.md new file mode 100644 index 0000000..8f09441 --- /dev/null +++ b/docs/superpowers/specs/2026-06-11-corner-decoration-design.md @@ -0,0 +1,81 @@ +# Corner Decoration — Design Spec + +**Date:** 2026-06-11 + +## Overview + +A 1-meter-tall decorative piece for a 90° wall corner. Twisted flat ribbon blade emerging from a hollow triangular spine. Split into 4 × 25cm 3D-printed pieces. Cable routing channel through the spine. + +## Geometry + +### Overall +- Total height: 1000mm (4 × 250mm pieces) +- Corner angle: 90° +- Ribbon protrusion: 80mm from corner to ribbon centre axis (along 45° diagonal); ribbon extends 50mm either side → near edge at 30mm, far edge at 130mm from corner +- Twist rate: 360° per 250mm piece (ribbon returns to same orientation at each joint) + +### Ribbon Blade +- Width: 100mm +- Thickness: 4mm +- Cross-section: flat rectangle (blade / ribbon profile) +- Path: helical, centered on the 45° diagonal axis of the corner +- One full twist (360°) per piece — top and bottom faces of each piece are geometrically identical + +### Triangular Spine +- Profile: right isosceles triangle, legs ~20mm × 20mm +- Sits flush in the 90° corner; hypotenuse face at 45° to both walls +- Two flanges (~5mm thick, 20mm wide) extend along each wall surface for gluing +- Cable channel: 12mm diameter circular hole through the centroid of the triangle, running full height +- Ribbon attaches at the midpoint of the hypotenuse face + +### Piece Joinery +- Alignment peg: 4mm square peg on top face, matching socket on bottom face +- Cable hole aligns automatically via peg +- Pieces glued wall-to-wall and to each other with construction adhesive + +## Print Settings + +| Parameter | Value | +|-----------|-------| +| Orientation | Vertical (upright) | +| Supports | Required (ribbon overhangs during twist) | +| Layer height | 0.15mm | +| Infill | 15% gyroid | +| Material | PLA | +| Pieces | 4 identical pieces | + +Fallback if supports leave bad finish: split each piece into spine + ribbon, print separately, glue. + +## Generation + +Python script using **cadquery**. All dimensions as named constants at top of file. Outputs one STL per piece (all 4 identical). + +### Key construction steps +1. Build spine: extrude right-isosceles triangle profile × 250mm, subtract 12mm cylinder for cable hole, add flanges on two legs +2. Build ribbon: sweep a 100mm × 4mm rectangle along a 250mm helix path (pitch = 250mm for 360°/piece), centered on the 45° diagonal axis +3. Boolean union spine + ribbon +4. Add alignment peg (top face) and socket (bottom face) +5. Export STL + +### Parameters (all tunable) +```python +PIECE_HEIGHT = 250 # mm +NUM_PIECES = 4 +RIBBON_WIDTH = 100 # mm +RIBBON_THICKNESS = 4 # mm +PROTRUSION = 80 # mm — corner to ribbon centre axis along 45° diagonal +TWIST_DEG = 360 # degrees per piece +SPINE_LEG = 20 # mm — triangle leg length +FLANGE_WIDTH = 20 # mm +FLANGE_THICKNESS = 5 # mm +CABLE_HOLE_DIA = 12 # mm +PEG_SIZE = 4 # mm square +PEG_HEIGHT = 5 # mm +``` + +## Mounting + +- Glue flanges to both walls with construction adhesive (e.g. Liquid Nails) +- Stack pieces bottom-up, aligning pegs +- Thread cable through spine channel before gluing upper pieces +- No visible hardware diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..9be3f88 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1781214844, + "narHash": "sha256-GiKi1nonuwTHG1mTrwFTllfMSN2rvHQZUgpW0nQX/qM=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "9bc9b4b4e7b1e7ce7ec2a8355b4369541b90cd6a", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "release-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..eb0ca79 --- /dev/null +++ b/flake.nix @@ -0,0 +1,63 @@ +{ + # 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"; + }; + } + ); +}