Files
KwabaTV/docs/superpowers/plans/2026-09-16-kwabatv-rebuild.md
T
GGBandClaude Sonnet 5 1b6ed31683 Correct plan's JAVA_HOME guidance to the actual working JDK
The plan assumed Android Studio's bundled JBR (JDK 25) would work
for Gradle 8.7 - it doesn't (crashes at startup). Task 1's actual
build used a separately-installed Temurin 21 instead, scoped
per-command to this project only. Updating the remaining tasks'
commands to match what was actually verified working.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHgWjmEJXsJEi1EVAoTp4K
2026-09-16 22:43:39 +02:00

24 KiB
Raw Blame History

KwabaTV Rebuild 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.

Goal: Rebuild the deleted KwabaTV Android app as a thin WebView shell pointed at the new /tv room list, forwarding real remote-control key events to the website's TV-mode key handling.

Architecture: A single-Activity app (MainActivity, plain android.app.Activity, no AndroidX — a WebView-only app needs none of it) that loads BuildConfig.WEBSITE_URL and intercepts DPAD/media-key KeyEvents in dispatchKeyEvent(), re-dispatching real (not JS-simulated) KeyEvents into the WebView with the mapping from the design spec. Fullscreen state comes from WebChromeClient.onShowCustomView/onHideCustomView; current-page tracking comes from WebViewClient.onPageFinished.

Tech Stack: Kotlin, Android Gradle Plugin 8.5.2, Gradle 8.7, compileSdk 34 / minSdk 21 / targetSdk 34 — the same combination documented as tested in the original (deleted) app. No test framework: this is a 4-file native app with no business logic to unit-test; verification is real gradlew assembleDebug builds (compiler-checked, unlike anything in this session's website work) plus manual sideload testing on the user's real Fire TV hardware.

Global Constraints

  • Package de.ggb.kwabatv, compileSdk 34 / minSdk 21 / targetSdk 34, AGP 8.5.2, Gradle 8.7 — from the design spec, carried over from the original app's tested configuration.
  • BuildConfig.WEBSITE_URL = "https://kwaba.whodovoodoo.pasteaf.de/tv" — the room list, not a specific room (per the design spec's rejection of a baked-in room ID).
  • No AndroidX, no Jetpack Compose, no ExoPlayer, no other dependencies beyond the Android SDK + Kotlin stdlib — this app is a WebView shell with no other UI, per the original app's minimal-dependency approach (confirmed still appropriate: the website already handles all playback/UI, WebView rendering is sufficient).
  • Every remote-control key must be forwarded via webView.dispatchKeyEvent() with a real KeyEvent object — never evaluateJavascript()-simulated events — because Chromium requires a trusted user gesture for autoplay-unlock and the Fullscreen API.
  • KEYCODE_MENU is passed through UNCHANGED (not intercepted/reconstructed) — the user already confirmed a real Fire TV remote's Menu button, received by a plain browser tab on the actual TV mode page, opens the popup. Passing the event through untouched (rather than intercepting and reconstructing it) is the closest possible match to that already-proven behavior.
  • KEYCODE_BACK's 3-state behavior (room list → no-op; in a room + fullscreen → send Escape; in a room + not fullscreen → navigate to /tv) from the design spec — implemented natively, not by relying on any website-side reaction to a key event.
  • Held DPAD Left/Right must be throttled to roughly one synthetic event per 200ms even though Android's own hardware key-repeat fires faster — un-throttled, this would flood the room's sync socket with seek commands broadcast to every viewer.
  • Debug builds (gradlew assembleDebug) are used throughout this plan for iteration and hardware sideload testing — Android auto-signs debug builds, no keystore needed, and Fire TV sideloading doesn't care whether an APK is a "debug" or "release" build. A signed release build (via Android Studio's Generate Signed Bundle / APK wizard, per the original app's own documented process) is a separate, later step this plan doesn't cover — out of scope until the app is otherwise working.
  • JAVA_HOME for all Gradle commands in this plan: C:\Users\GGB\jdks\jdk-21.0.12.1+1 (a separately-installed Temurin 21, added specifically for this project). Not Android Studio's bundled JBR (JDK 25) — Gradle 8.7 crashes at startup on JDK 25 (What went wrong: 25.0.3, a bare version-string error, not a real message). Set JAVA_HOME inline per-command (e.g. JAVA_HOME="C:\Users\GGB\jdks\jdk-21.0.12.1+1" ./gradlew.bat ...) — scoped to this project's Gradle invocations only, never as a system-wide or user-profile environment variable, so Android Studio's own JBR stays what the IDE itself uses.
  • Reuse the branding assets already in C:\Projekte\kwaba\Claude outputs\ (ic_launcher_art.png, 512×512; banner_tv_art.png, 640×360) rather than regenerating them.

Task 1: Gradle project scaffold + first successful build

Files:

  • Create: settings.gradle.kts
  • Create: build.gradle.kts (root)
  • Create: gradle.properties
  • Create: app/build.gradle.kts
  • Create: app/src/main/AndroidManifest.xml
  • Create: app/src/main/res/xml/network_security_config.xml
  • Create: app/src/main/res/values/strings.xml
  • Create: app/src/main/java/de/ggb/kwabatv/MainActivity.kt (minimal placeholder for this task — just loads the URL, no key handling yet; Task 3 replaces its contents)
  • Create (generated by the Gradle wrapper bootstrap, not hand-authored): gradlew, gradlew.bat, gradle/wrapper/gradle-wrapper.properties, gradle/wrapper/gradle-wrapper.jar

Interfaces:

  • Consumes: nothing (first task).

  • Produces: a gradlew wrapper pinned to Gradle 8.7 that every later task's build commands use; BuildConfig.WEBSITE_URL (used by Task 3's real MainActivity).

  • Step 1: Write settings.gradle.kts

pluginManagement {
    repositories {
        google()
        mavenCentral()
        gradlePluginPortal()
    }
}
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
    }
}
rootProject.name = "KwabaTV"
include(":app")
  • Step 2: Write the root build.gradle.kts
plugins {
    id("com.android.application") version "8.5.2" apply false
    id("org.jetbrains.kotlin.android") version "1.9.24" apply false
}
  • Step 3: Write gradle.properties
org.gradle.jvmargs=-Xmx2048m
android.useAndroidX=false
kotlin.code.style=official

(android.useAndroidX=false is deliberate — this app has no AndroidX dependency to begin with, per Global Constraints, so nothing needs the AndroidX artifact namespace.)

  • Step 4: Write app/build.gradle.kts
plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
}

android {
    namespace = "de.ggb.kwabatv"
    compileSdk = 34

    defaultConfig {
        applicationId = "de.ggb.kwabatv"
        minSdk = 21
        targetSdk = 34
        versionCode = 1
        versionName = "1.0"
        buildConfigField("String", "WEBSITE_URL", "\"https://kwaba.whodovoodoo.pasteaf.de/tv\"")
    }

    buildTypes {
        release {
            isMinifyEnabled = false
        }
    }

    buildFeatures {
        buildConfig = true
    }

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_1_8
        targetCompatibility = JavaVersion.VERSION_1_8
    }

    kotlinOptions {
        jvmTarget = "1.8"
    }
}
  • Step 5: Write the manifest

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" />
    <uses-feature android:name="android.software.leanback" android:required="false" />
    <uses-feature android:name="android.hardware.touchscreen" android:required="false" />

    <application
        android:allowBackup="true"
        android:label="@string/app_name"
        android:networkSecurityConfig="@xml/network_security_config"
        android:theme="@android:style/Theme.NoTitleBar.Fullscreen">

        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:configChanges="orientation|screenSize|keyboardHidden"
            android:screenOrientation="landscape">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

(android:icon/android:banner are deliberately omitted here — Task 2 adds the real branding drawables and those two attributes together, so this task's manifest never references a resource that doesn't exist yet. The android.hardware.touchscreen feature declared as NOT required is what lets Fire TV / the Amazon Appstore accept this as a TV app rather than rejecting it as phone-only.)

  • Step 6: Write the network security config

Write app/src/main/res/xml/network_security_config.xml:

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <base-config>
        <trust-anchors>
            <certificates src="system" />
        </trust-anchors>
    </base-config>
</network-security-config>

(System trust anchors only — the domain runs Let's Encrypt, a public CA, so no certificate pinning is needed.)

  • Step 7: Write the app name string

Write app/src/main/res/values/strings.xml:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="app_name">KwabaTV</string>
</resources>
  • Step 8: Write a minimal placeholder MainActivity.kt

Write app/src/main/java/de/ggb/kwabatv/MainActivity.kt:

package de.ggb.kwabatv

import android.app.Activity
import android.os.Bundle
import android.webkit.WebView

class MainActivity : Activity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val webView = WebView(this)
        setContentView(webView)
        webView.settings.javaScriptEnabled = true
        webView.settings.domStorageEnabled = true
        webView.loadUrl(BuildConfig.WEBSITE_URL)
    }
}

(Deliberately minimal — this task's only goal is proving the whole toolchain builds and installs. Task 3 replaces this file's contents with the full key-mapping/fullscreen/back-button logic.)

  • Step 9: Bootstrap the Gradle wrapper

There is no gradle binary on this machine's PATH and no pre-existing wrapper to reuse (the original app's folder was deleted). Download the real Gradle 8.7 distribution once, use it to generate the wrapper files for this project, then discard the downloaded copy — every later command in this plan uses the generated ./gradlew, never a system-wide Gradle.

cd C:\Projekte\kwabatv
Invoke-WebRequest -Uri "https://services.gradle.org/distributions/gradle-8.7-bin.zip" -OutFile "$env:TEMP\gradle-8.7-bin.zip"
Expand-Archive -Path "$env:TEMP\gradle-8.7-bin.zip" -DestinationPath "$env:TEMP\gradle-8.7" -Force
$env:JAVA_HOME = "C:\Users\GGB\jdks\jdk-21.0.12.1+1"
& "$env:TEMP\gradle-8.7\gradle-8.7\bin\gradle.bat" wrapper --gradle-version 8.7 --distribution-type bin
Remove-Item -Recurse -Force "$env:TEMP\gradle-8.7", "$env:TEMP\gradle-8.7-bin.zip"

Expected: gradlew, gradlew.bat, and gradle/wrapper/gradle-wrapper.properties + gradle-wrapper.jar now exist in C:\Projekte\kwabatv.

  • Step 10: First build
cd C:\Projekte\kwabatv
$env:JAVA_HOME = "C:\Users\GGB\jdks\jdk-21.0.12.1+1"
.\gradlew.bat assembleDebug

Expected: BUILD SUCCESSFUL, and app\build\outputs\apk\debug\app-debug.apk exists. This is a real compiler-checked build (unlike anything in this session's prior website work, which had no equivalent local feedback loop) — a genuine failure here means a real mistake in the files above, not an environment limitation.

If Android SDK components are missing (a fresh SDK install often lacks the exact compileSdk 34 platform or build-tools), the error will name exactly what's missing; install it via sdkmanager (under the SDK's cmdline-tools if present) or open the project once in Android Studio and let it prompt to install the missing SDK components, then retry.

  • Step 11: Commit
cd C:\Projekte\kwabatv
git add settings.gradle.kts build.gradle.kts gradle.properties gradlew gradlew.bat gradle/ app/build.gradle.kts app/src/main/AndroidManifest.xml app/src/main/res/xml/network_security_config.xml app/src/main/res/values/strings.xml app/src/main/java/de/ggb/kwabatv/MainActivity.kt
git commit -m "Bootstrap KwabaTV Gradle project, minimal WebView shell"

Task 2: Branding assets

Files:

  • Create: app/src/main/res/drawable/ic_launcher_art.png (copied from C:\Projekte\kwaba\Claude outputs\ic_launcher_art.png)
  • Create: app/src/main/res/drawable/banner_tv_art.png (copied from C:\Projekte\kwaba\Claude outputs\banner_tv_art.png)
  • Modify: app/src/main/AndroidManifest.xml (add android:icon/android:banner)

Interfaces:

  • Consumes: Task 1's manifest and successful build.

  • Produces: nothing later tasks depend on programmatically — purely visual.

  • Step 1: Copy the assets

Copy-Item "C:\Projekte\kwaba\Claude outputs\ic_launcher_art.png" "C:\Projekte\kwabatv\app\src\main\res\drawable\ic_launcher_art.png"
Copy-Item "C:\Projekte\kwaba\Claude outputs\banner_tv_art.png" "C:\Projekte\kwabatv\app\src\main\res\drawable\banner_tv_art.png"
  • Step 2: Wire them into the manifest

In app/src/main/AndroidManifest.xml, the <application> tag currently reads:

    <application
        android:allowBackup="true"
        android:label="@string/app_name"
        android:networkSecurityConfig="@xml/network_security_config"
        android:theme="@android:style/Theme.NoTitleBar.Fullscreen">

Change to:

    <application
        android:allowBackup="true"
        android:icon="@drawable/ic_launcher_art"
        android:banner="@drawable/banner_tv_art"
        android:label="@string/app_name"
        android:networkSecurityConfig="@xml/network_security_config"
        android:theme="@android:style/Theme.NoTitleBar.Fullscreen">
  • Step 3: Rebuild to confirm the resources compile
cd C:\Projekte\kwabatv
$env:JAVA_HOME = "C:\Users\GGB\jdks\jdk-21.0.12.1+1"
.\gradlew.bat assembleDebug

Expected: BUILD SUCCESSFUL. A resource-linking error here would name the exact bad reference (e.g. a typo'd drawable name) — fix and retry rather than guessing.

  • Step 4: Commit
cd C:\Projekte\kwabatv
git add app/src/main/res/drawable/ic_launcher_art.png app/src/main/res/drawable/banner_tv_art.png app/src/main/AndroidManifest.xml
git commit -m "Add app icon and TV banner"

Task 3: Key mapping, fullscreen tracking, back-button navigation

Files:

  • Modify: app/src/main/java/de/ggb/kwabatv/MainActivity.kt (replace the placeholder from Task 1 entirely)

Interfaces:

  • Consumes: BuildConfig.WEBSITE_URL (Task 1).

  • Produces: nothing later tasks depend on programmatically — Task 4 is manual hardware verification of this task's behavior.

  • Step 1: Write the full MainActivity.kt

Replace the entire contents of app/src/main/java/de/ggb/kwabatv/MainActivity.kt with:

package de.ggb.kwabatv

import android.app.Activity
import android.net.Uri
import android.os.Bundle
import android.os.SystemClock
import android.view.KeyEvent
import android.view.View
import android.webkit.PermissionRequest
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.webkit.WebViewClient

/**
 * Reine WebView-Huelle. Fernbedienungstasten werden in dispatchKeyEvent()
 * abgefangen und als echte (nicht per JS simulierte) KeyEvents an das
 * WebView weitergereicht - Chromium verlangt fuer die Fullscreen-API und
 * den einmaligen Autoplay-Ton-Klick eine vertrauenswuerdige Nutzereingabe,
 * die ein simuliertes KeyboardEvent nicht liefert.
 *
 * KEYCODE_MENU wird bewusst NICHT abgefangen: das Oeffnen des TV-Menue-
 * Popups wurde bereits gegen die echte Website in einem normalen Browser
 * auf echter Fire-TV-Hardware bestaetigt, ohne dass diese App beteiligt
 * war - unveraendertes Durchreichen ist die naechste Annaeherung an genau
 * dieses schon erprobte Verhalten.
 */
class MainActivity : Activity() {

    private lateinit var webView: WebView
    private var fullscreenView: View? = null
    private var currentPath: String = "/tv"
    private var lastThrottledSeekAt = 0L

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        webView = WebView(this)
        setContentView(webView)

        webView.settings.javaScriptEnabled = true
        webView.settings.domStorageEnabled = true
        webView.settings.mediaPlaybackRequiresUserGesture = false

        webView.webViewClient = object : WebViewClient() {
            override fun onPageFinished(view: WebView, url: String) {
                super.onPageFinished(view, url)
                currentPath = Uri.parse(url).path ?: "/tv"
            }
        }

        webView.webChromeClient = object : WebChromeClient() {
            override fun onShowCustomView(view: View, callback: CustomViewCallback) {
                fullscreenView = view
            }

            override fun onHideCustomView() {
                fullscreenView = null
            }

            override fun onPermissionRequest(request: PermissionRequest) {
                // Kein Mikrofon/Kamera im TV-Modus - Fire-TV-Sticks haben
                // ohnehin meist keine Kamera, siehe Fixed Product Decisions.
                request.deny()
            }
        }

        webView.loadUrl(BuildConfig.WEBSITE_URL)
    }

    override fun onDestroy() {
        webView.destroy()
        super.onDestroy()
    }

    private fun isFullscreen() = fullscreenView != null
    private fun isRoomList() = currentPath == "/tv"

    private fun sendKey(keyCode: Int, shift: Boolean = false) {
        val metaState = if (shift) KeyEvent.META_SHIFT_ON else 0
        val now = SystemClock.uptimeMillis()
        webView.dispatchKeyEvent(KeyEvent(now, now, KeyEvent.ACTION_DOWN, keyCode, 0, metaState))
        webView.dispatchKeyEvent(KeyEvent(now, now, KeyEvent.ACTION_UP, keyCode, 0, metaState))
    }

    /** Tippen = 5s-Schritt (kein Shift), gehalten = 30s-Schritt (mit Shift),
     * gedrosselt auf ~1 Ereignis pro 200ms - Androids eigene Tastenwieder-
     * holung feuert schneller als das und wuerde sonst den Sync-Socket des
     * Raums mit Sprungbefehlen ueberfluten, die an alle Zuschauer gehen. */
    private fun handleSeek(keyCode: Int, isRepeat: Boolean) {
        if (!isRepeat) { sendKey(keyCode); return }
        val now = SystemClock.uptimeMillis()
        if (now - lastThrottledSeekAt < SEEK_THROTTLE_MS) return
        lastThrottledSeekAt = now
        sendKey(keyCode, shift = true)
    }

    override fun dispatchKeyEvent(event: KeyEvent): Boolean {
        if (event.action != KeyEvent.ACTION_DOWN) {
            return when (event.keyCode) {
                KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
                KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT,
                KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN,
                KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_MEDIA_NEXT,
                KeyEvent.KEYCODE_BACK -> true // ACTION_UP derselben Tasten verschlucken, wir haben schon auf ACTION_DOWN reagiert
                else -> super.dispatchKeyEvent(event)
            }
        }
        return when (event.keyCode) {
            KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
                sendKey(KeyEvent.KEYCODE_SPACE)
                true
            }
            KeyEvent.KEYCODE_DPAD_LEFT -> {
                handleSeek(KeyEvent.KEYCODE_DPAD_LEFT, event.repeatCount > 0)
                true
            }
            KeyEvent.KEYCODE_DPAD_RIGHT -> {
                handleSeek(KeyEvent.KEYCODE_DPAD_RIGHT, event.repeatCount > 0)
                true
            }
            KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
                sendKey(KeyEvent.KEYCODE_P)
                true
            }
            KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_MEDIA_NEXT -> {
                sendKey(KeyEvent.KEYCODE_N)
                true
            }
            KeyEvent.KEYCODE_BACK -> {
                when {
                    isRoomList() -> { /* verschluckt, keine weitere Aktion */ }
                    isFullscreen() -> sendKey(KeyEvent.KEYCODE_ESCAPE)
                    else -> webView.loadUrl(BuildConfig.WEBSITE_URL)
                }
                true
            }
            else -> super.dispatchKeyEvent(event)
        }
    }

    companion object {
        private const val SEEK_THROTTLE_MS = 200L
    }
}
  • Step 2: Rebuild
cd C:\Projekte\kwabatv
$env:JAVA_HOME = "C:\Users\GGB\jdks\jdk-21.0.12.1+1"
.\gradlew.bat assembleDebug

Expected: BUILD SUCCESSFUL.

  • Step 3: Sideload onto real hardware

Either of the two methods documented in the original app's own README (Claude outputs/README.md) work unchanged for a debug APK:

  • ADB (fastest if the device is reachable on the local network): on the Fire TV Stick, enable Einstellungen → Mein Fire TV → Entwickleroptionen → ADB-Debugging, note its IP, then from this machine:

    & "$env:LOCALAPPDATA\Android\Sdk\platform-tools\adb.exe" connect <Fire-TV-IP>:5555
    & "$env:LOCALAPPDATA\Android\Sdk\platform-tools\adb.exe" install -r C:\Projekte\kwabatv\app\build\outputs\apk\debug\app-debug.apk
    
  • Downloader app: host app-debug.apk somewhere reachable (e.g. python -m http.server on the local network) and enter that URL in the Fire TV Stick's Downloader app.

  • Step 4: Commit

cd C:\Projekte\kwabatv
git add app/src/main/java/de/ggb/kwabatv/MainActivity.kt
git commit -m "Add remote-control key mapping, fullscreen tracking, back-button navigation"

Task 4: Hardware verification

Files: none (verification only).

Interfaces:

  • Consumes: the installed app from Task 3.

  • Produces: nothing new — this task is the feature's acceptance check, against the design spec's key-mapping table.

  • Step 1: Confirm the app launches into the room list

Launch KwabaTV from the Fire TV home screen (it should appear there via the LEANBACK_LAUNCHER category). Confirm it loads the /tv room list (prompts for login on first launch if no session exists yet — log in once via the on-screen keyboard).

  • Step 2: Verify each mapping from the design spec's table

With a room list or a room open as appropriate:

  • DPAD OK/Center or Play/Pause media key → Play/Pause in a room.

  • DPAD Left/Right tap → 5s seek. DPAD Left/Right held → confirm it seeks in 30s steps roughly every 0.2s, not a flood of smaller jumps (this is the one behavior that can't be checked by reading code alone — confirm by ear/eye against the video's visible timestamp).

  • DPAD Up/Down (and Prev/Next media keys, if the remote has them) → previous/next title.

  • Menu button → the TV menu popup opens (already confirmed once via a plain browser; confirm it also works through this app).

  • Back button: in the room list → nothing happens. In a room, not fullscreen → returns to the room list. In a room, fullscreen → exits fullscreen only, stays in the room.

  • Home button → native Fire TV home (unaffected by this app, nothing to verify beyond "it still works").

  • Step 3: Cross-check against the second device

Repeat Step 2's checks on the other available remote/hardware combination, since different remotes may send different keycodes for the same physical buttons (especially Prev/Next media keys, which not every remote has).

  • Step 4: Record completion

No code changes in this task. Note any mapping that didn't behave as expected, with the exact remote/button and observed vs. expected behavior — that becomes a small follow-up fix against Task 3, not a new task. If everything in Step 2 checks out on at least one device, the rebuild is functionally complete per the approved design spec; Step 3's cross-device check is a robustness bonus, not a blocker.