Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
647cbf6a9a | ||
|
|
49061f3d32 | ||
|
|
29065f8c29 | ||
|
|
d67c2a54de | ||
|
|
286872c972 | ||
|
|
5cc18d760d | ||
|
|
ef3648ad14 | ||
|
|
aac37265a3 | ||
|
|
ba422bf089 | ||
|
|
3412357526 | ||
|
|
5a8f593b98 | ||
|
|
e3f9a07a67 | ||
|
|
1b6ed31683 | ||
|
|
379ae78bbf | ||
|
|
515bab293f |
@@ -0,0 +1,10 @@
|
||||
*.iml
|
||||
.gradle/
|
||||
local.properties
|
||||
.idea/
|
||||
.DS_Store
|
||||
build/
|
||||
captures/
|
||||
.externalNativeBuild/
|
||||
.cxx/
|
||||
*.apk
|
||||
@@ -0,0 +1,45 @@
|
||||
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 = 2
|
||||
versionName = "1.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"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
// Android's org.json classes are stubbed out (throw at runtime) in plain JVM unit
|
||||
// tests unless a real implementation is on the test classpath - this is that
|
||||
// implementation, test-only, never shipped in the APK.
|
||||
testImplementation("org.json:json:20231013")
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?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-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<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:icon="@mipmap/ic_launcher_art"
|
||||
android:banner="@mipmap/banner_tv_art"
|
||||
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>
|
||||
@@ -0,0 +1,247 @@
|
||||
package de.ggb.kwabatv
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.app.DownloadManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.SystemClock
|
||||
import android.provider.Settings
|
||||
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 NICHT als KEYCODE_MENU ins WebView weitergereicht,
|
||||
* sondern als KEYCODE_X. Grund (per Diagnose-Log bestaetigt): Androids
|
||||
* WebView.dispatchKeyEvent() liefert "System"-Keycodes (KEYCODE_MENU,
|
||||
* HOME, BACK, CALL, VOLUME_*, ...) grundsaetzlich nicht an den Seiteninhalt
|
||||
* aus - sendKey(KEYCODE_MENU) kam zwar in dispatchKeyEvent() an, aber
|
||||
* webView.dispatchKeyEvent() meldete downConsumed=false/upConsumed=false
|
||||
* und JS bekam weder keydown- noch contextmenu-Events. Ein gewoehnlicher
|
||||
* Buchstaben-Keycode wie KEYCODE_P (fuer DPAD_UP) wird dagegen anstandslos
|
||||
* durchgereicht (downConsumed=true). Die Website (room.js) behandelt 'x'
|
||||
* deshalb als Alias fuer die 'ContextMenu'-Taste, ausschliesslich fuer
|
||||
* diesen App-Anwendungsfall.
|
||||
*
|
||||
* DPAD Center/Up/Down/Left/Right, Menu und die entsprechenden Medientasten
|
||||
* werden NUR innerhalb eines Raums (Pfad "/tv/<id>") remapped - siehe
|
||||
* isInRoom(). Auf jeder anderen Seite (Raumliste "/tv", Login, Signup, ...)
|
||||
* muessen sie unveraendert durchgereicht werden, sonst zerschiesst die
|
||||
* Remapping-Logik dort Texteingaben. Zwei konkrete Bugs kamen genau daher,
|
||||
* dass frueher statt isInRoom() die Umkehrung von isRoomList() geprueft
|
||||
* wurde (also "ueberall ausser der Raumliste"), was auch Login/Signup
|
||||
* einschloss: auf der Raumliste bewegte sich der Fokus zwischen den
|
||||
* Raum-Kacheln nicht (DPAD_DOWN/CENTER kamen als 'n'/Space statt als
|
||||
* ArrowDown/Enter an), und auf der Login-Seite tippte DPAD OK ein
|
||||
* Leerzeichen in das fokussierte Textfeld statt das Formular zu bestaetigen
|
||||
* (DPAD_CENTER kam als Space-Taste an statt als echtes Enter).
|
||||
*/
|
||||
class MainActivity : Activity() {
|
||||
|
||||
private lateinit var webView: WebView
|
||||
private var fullscreenView: View? = null
|
||||
private var currentPath: String = "/tv"
|
||||
private var lastThrottledSeekAt = 0L
|
||||
private var pendingDownloadId: Long = -1
|
||||
private var receiverRegistered = false
|
||||
|
||||
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)
|
||||
checkForUpdateInBackground()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
webView.destroy()
|
||||
if (receiverRegistered) { unregisterReceiver(downloadReceiver); receiverRegistered = false }
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- In-App-Update
|
||||
Prueft bei jedem Start das oeffentliche KwabaTV-Gitea-Repo auf ein neueres
|
||||
Release (siehe UpdateChecker.kt) und bietet es ueber Androids eigenen
|
||||
Paket-Installer an - nie ohne Bestaetigung, das Betriebssystem selbst
|
||||
erlaubt keine stille Installation. Ein fehlgeschlagener Check ist immer
|
||||
lautlos: kein Fehlerdialog, einfach beim naechsten Start erneut versuchen. */
|
||||
|
||||
private fun checkForUpdateInBackground() {
|
||||
Thread {
|
||||
val update = UpdateChecker.checkForUpdate(BuildConfig.VERSION_NAME)
|
||||
if (update != null) runOnUiThread { showUpdateDialog(update) }
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun showUpdateDialog(update: UpdateInfo) {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("Update verfügbar")
|
||||
.setMessage("KwabaTV v${update.version} ist verfügbar.")
|
||||
.setPositiveButton("Aktualisieren") { _, _ -> startUpdate(update) }
|
||||
.setNegativeButton("Später", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
/* "Unbekannte Apps installieren" ist fuer neue Apps ab Android 8 (API 26)
|
||||
pro App einzeln zu erlauben - ohne das wuerde der Installations-Intent
|
||||
unten stillschweigend nichts tun. Statt automatisch nach der Rueckkehr
|
||||
aus den Einstellungen weiterzumachen (mehr Zustand, mehr Fehlerquellen),
|
||||
muss der Nutzer "Aktualisieren" nach dem Erlauben schlicht erneut
|
||||
druecken - siehe Design-Spec. */
|
||||
private fun startUpdate(update: UpdateInfo) {
|
||||
val canInstall = Build.VERSION.SDK_INT < Build.VERSION_CODES.O || packageManager.canRequestPackageInstalls()
|
||||
if (!canInstall) {
|
||||
startActivity(Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName")))
|
||||
return
|
||||
}
|
||||
downloadApk(update.apkDownloadUrl)
|
||||
}
|
||||
|
||||
private fun downloadApk(url: String) {
|
||||
val request = DownloadManager.Request(Uri.parse(url))
|
||||
.setTitle("KwabaTV Update")
|
||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
.setDestinationInExternalFilesDir(this, null, "KwabaTV-update.apk")
|
||||
val downloadManager = getSystemService(DOWNLOAD_SERVICE) as DownloadManager
|
||||
pendingDownloadId = downloadManager.enqueue(request)
|
||||
// Ab Android 13 (API 33) muss jeder dynamisch registrierte Receiver
|
||||
// explizit als (nicht-)exportiert markiert sein, sonst wirft
|
||||
// registerReceiver() eine SecurityException.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
registerReceiver(downloadReceiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||
registerReceiver(downloadReceiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE))
|
||||
}
|
||||
receiverRegistered = true
|
||||
}
|
||||
|
||||
private val downloadReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) != pendingDownloadId) return
|
||||
if (receiverRegistered) { unregisterReceiver(this); receiverRegistered = false }
|
||||
val downloadManager = getSystemService(DOWNLOAD_SERVICE) as DownloadManager
|
||||
// getUriForDownloadedFile() returns null for a failed download - that failure is
|
||||
// silent for now too, matching the update check's own error handling (see spec).
|
||||
val uri = downloadManager.getUriForDownloadedFile(pendingDownloadId) ?: return
|
||||
installApk(uri)
|
||||
}
|
||||
}
|
||||
|
||||
private fun installApk(uri: Uri) {
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
private fun isRoomList() = currentPath == "/tv"
|
||||
private fun isInRoom() = currentPath.startsWith("/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)
|
||||
}
|
||||
|
||||
/** Ob dispatchKeyEvent diese Taste ueberhaupt anfasst - fuer ACTION_DOWN
|
||||
* und ACTION_UP gleich ausgewertet, damit nie nur die eine Haelfte eines
|
||||
* Tastendrucks abgefangen wird (sonst haengt Chromiums interner
|
||||
* Tastenstatus fest, weil ein Keydown ohne passendes Keyup ankaeme). */
|
||||
private fun shouldIntercept(keyCode: Int): Boolean = when (keyCode) {
|
||||
KeyEvent.KEYCODE_BACK -> true
|
||||
KeyEvent.KEYCODE_MENU -> isRoomList() || isInRoom()
|
||||
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT,
|
||||
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
|
||||
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN,
|
||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_MEDIA_NEXT -> isInRoom()
|
||||
else -> false
|
||||
}
|
||||
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
if (!shouldIntercept(event.keyCode)) return super.dispatchKeyEvent(event)
|
||||
if (event.action != KeyEvent.ACTION_DOWN) return true // ACTION_UP der abgefangenen Taste verschlucken, ACTION_DOWN hat schon reagiert
|
||||
|
||||
when (event.keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> sendKey(KeyEvent.KEYCODE_SPACE)
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> handleSeek(KeyEvent.KEYCODE_DPAD_LEFT, event.repeatCount > 0)
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> handleSeek(KeyEvent.KEYCODE_DPAD_RIGHT, event.repeatCount > 0)
|
||||
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_MEDIA_PREVIOUS -> sendKey(KeyEvent.KEYCODE_P)
|
||||
KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_MEDIA_NEXT -> sendKey(KeyEvent.KEYCODE_N)
|
||||
KeyEvent.KEYCODE_MENU -> sendKey(KeyEvent.KEYCODE_X)
|
||||
/* BACK entscheidet NICHT mehr selbst zwischen "Popup schliessen",
|
||||
"Vollbild verlassen" und "zur Raumliste zurueck" - das kann nur
|
||||
JS wissen (siehe room.js/tv-rooms.js keydown-Handler), weil nur
|
||||
dort bekannt ist, ob z. B. das Menue-Popup gerade offen ist. Ein
|
||||
fixer Fall genau darauf beruhte der Bug, dass BACK bei offenem
|
||||
Popup direkt zur Raumliste sprang, statt das Popup zu schliessen.
|
||||
Auf Login/Signup/sonstigen Seiten (weder Raumliste noch Raum)
|
||||
bleibt BACK bewusst wirkungslos - sonst gehen unfertig
|
||||
ausgefuellte Formulare verloren. */
|
||||
KeyEvent.KEYCODE_BACK -> if (isRoomList() || isInRoom()) sendKey(KeyEvent.KEYCODE_ESCAPE)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SEEK_THROTTLE_MS = 200L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package de.ggb.kwabatv
|
||||
|
||||
import org.json.JSONArray
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
/** A qualifying update found on Gitea: display version and the APK's direct download URL. */
|
||||
data class UpdateInfo(val version: String, val apkDownloadUrl: String)
|
||||
|
||||
/**
|
||||
* Checks the public KwabaTV Gitea repo for a newer release. Fully self-contained - no
|
||||
* kwaba-sync server involvement, since GGB/KwabaTV is a public repo and its releases API
|
||||
* needs no authentication. See docs/superpowers/specs/2026-09-27-in-app-updates-design.md.
|
||||
*/
|
||||
object UpdateChecker {
|
||||
private const val RELEASES_API_URL = "https://git.whodovoodoo.de/api/v1/repos/GGB/KwabaTV/releases"
|
||||
private const val DOWNLOAD_BASE_URL = "https://git.whodovoodoo.de/GGB/KwabaTV/releases/download"
|
||||
private const val ASSET_NAME = "KwabaTV.apk"
|
||||
private val VERSION_RE = Regex("""^(\d+)\.(\d+)\.(\d+)$""")
|
||||
|
||||
/** Parses a bare "X.Y.Z" version string (no "v" prefix) into [major, minor, patch], or null if malformed. */
|
||||
fun parseVersion(version: String): List<Int>? {
|
||||
val m = VERSION_RE.matchEntire(version) ?: return null
|
||||
return m.groupValues.drop(1).map { it.toInt() }
|
||||
}
|
||||
|
||||
/** Positive if [a] is newer than [b], negative if older, 0 if equal. Both must be 3-element lists. */
|
||||
fun compareVersions(a: List<Int>, b: List<Int>): Int {
|
||||
for (i in 0..2) {
|
||||
val diff = a[i] - b[i]
|
||||
if (diff != 0) return diff
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the newest qualifying release from a Gitea releases API JSON array: not a draft,
|
||||
* not a prerelease, tag matches "vX.Y.Z", newer than [currentVersion], and has a
|
||||
* "KwabaTV.apk" asset. Returns null if none qualify or the JSON is malformed. The download
|
||||
* URL is built manually from tag + asset name (same approach as kwaba-sync's own
|
||||
* src/desktop-release.js) rather than trusting the API response's browser_download_url.
|
||||
*/
|
||||
fun pickUpdate(releasesJson: String, currentVersion: String): UpdateInfo? {
|
||||
val current = parseVersion(currentVersion) ?: return null
|
||||
val releases = try { JSONArray(releasesJson) } catch (e: Exception) { return null }
|
||||
|
||||
var best: Pair<List<Int>, UpdateInfo>? = null
|
||||
for (i in 0 until releases.length()) {
|
||||
val release = releases.optJSONObject(i) ?: continue
|
||||
if (release.optBoolean("draft", false) || release.optBoolean("prerelease", false)) continue
|
||||
|
||||
val tag = release.optString("tag_name", "")
|
||||
if (!tag.startsWith("v")) continue
|
||||
val version = parseVersion(tag.substring(1)) ?: continue
|
||||
if (compareVersions(version, current) <= 0) continue
|
||||
|
||||
val assets = release.optJSONArray("assets") ?: continue
|
||||
var hasApk = false
|
||||
for (j in 0 until assets.length()) {
|
||||
if (assets.optJSONObject(j)?.optString("name") == ASSET_NAME) { hasApk = true; break }
|
||||
}
|
||||
if (!hasApk) continue
|
||||
|
||||
if (best == null || compareVersions(version, best!!.first) > 0) {
|
||||
best = version to UpdateInfo(version.joinToString("."), "$DOWNLOAD_BASE_URL/$tag/$ASSET_NAME")
|
||||
}
|
||||
}
|
||||
return best?.second
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the releases list and returns the newest qualifying update, or null - including
|
||||
* on any failure (network unreachable, non-200 response, malformed body). An update check
|
||||
* is a nice-to-have; it must never be able to crash or visibly disrupt normal app use.
|
||||
*/
|
||||
fun checkForUpdate(currentVersion: String): UpdateInfo? {
|
||||
return try {
|
||||
val connection = URL(RELEASES_API_URL).openConnection() as HttpURLConnection
|
||||
connection.connectTimeout = 8000
|
||||
connection.readTimeout = 8000
|
||||
connection.requestMethod = "GET"
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
if (connection.responseCode != 200) return null
|
||||
val body = connection.inputStream.bufferedReader().use { it.readText() }
|
||||
pickUpdate(body, currentVersion)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">KwabaTV</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
@@ -0,0 +1,137 @@
|
||||
package de.ggb.kwabatv
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class UpdateCheckerTest {
|
||||
|
||||
@Test
|
||||
fun `parseVersion parses a valid three-part version`() {
|
||||
assertEquals(listOf(1, 2, 3), UpdateChecker.parseVersion("1.2.3"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseVersion rejects a two-part version`() {
|
||||
assertNull(UpdateChecker.parseVersion("1.0"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseVersion rejects a version with a v prefix`() {
|
||||
assertNull(UpdateChecker.parseVersion("v1.2.3"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseVersion rejects non-numeric input`() {
|
||||
assertNull(UpdateChecker.parseVersion("a.b.c"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compareVersions is positive when a is newer in the minor position`() {
|
||||
assertTrue(UpdateChecker.compareVersions(listOf(1, 1, 0), listOf(1, 0, 9)) > 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compareVersions is negative when a is older in the patch position`() {
|
||||
assertTrue(UpdateChecker.compareVersions(listOf(1, 0, 0), listOf(1, 0, 1)) < 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compareVersions is zero for equal versions`() {
|
||||
assertEquals(0, UpdateChecker.compareVersions(listOf(2, 3, 4), listOf(2, 3, 4)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pickUpdate returns the newer release when one qualifies`() {
|
||||
val json = """
|
||||
[
|
||||
{
|
||||
"tag_name": "v1.1.0",
|
||||
"draft": false,
|
||||
"prerelease": false,
|
||||
"assets": [ { "name": "KwabaTV.apk" } ]
|
||||
}
|
||||
]
|
||||
""".trimIndent()
|
||||
val result = UpdateChecker.pickUpdate(json, "1.0.0")
|
||||
assertEquals(
|
||||
UpdateInfo("1.1.0", "https://git.whodovoodoo.de/GGB/KwabaTV/releases/download/v1.1.0/KwabaTV.apk"),
|
||||
result
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pickUpdate returns null when the release is not newer`() {
|
||||
val json = """
|
||||
[
|
||||
{ "tag_name": "v1.0.0", "draft": false, "prerelease": false,
|
||||
"assets": [ { "name": "KwabaTV.apk" } ] }
|
||||
]
|
||||
""".trimIndent()
|
||||
assertNull(UpdateChecker.pickUpdate(json, "1.0.0"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pickUpdate skips draft releases`() {
|
||||
val json = """
|
||||
[
|
||||
{ "tag_name": "v9.9.9", "draft": true, "prerelease": false,
|
||||
"assets": [ { "name": "KwabaTV.apk" } ] }
|
||||
]
|
||||
""".trimIndent()
|
||||
assertNull(UpdateChecker.pickUpdate(json, "1.0.0"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pickUpdate skips prerelease releases`() {
|
||||
val json = """
|
||||
[
|
||||
{ "tag_name": "v9.9.9", "draft": false, "prerelease": true,
|
||||
"assets": [ { "name": "KwabaTV.apk" } ] }
|
||||
]
|
||||
""".trimIndent()
|
||||
assertNull(UpdateChecker.pickUpdate(json, "1.0.0"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pickUpdate skips a release with no matching apk asset`() {
|
||||
val json = """
|
||||
[
|
||||
{ "tag_name": "v1.1.0", "draft": false, "prerelease": false,
|
||||
"assets": [ { "name": "checksums.txt" } ] }
|
||||
]
|
||||
""".trimIndent()
|
||||
assertNull(UpdateChecker.pickUpdate(json, "1.0.0"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pickUpdate returns null for malformed json`() {
|
||||
assertNull(UpdateChecker.pickUpdate("not json", "1.0.0"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pickUpdate returns null when currentVersion itself is malformed`() {
|
||||
val json = """
|
||||
[
|
||||
{ "tag_name": "v1.1.0", "draft": false, "prerelease": false,
|
||||
"assets": [ { "name": "KwabaTV.apk" } ] }
|
||||
]
|
||||
""".trimIndent()
|
||||
assertNull(UpdateChecker.pickUpdate(json, "not-a-version"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pickUpdate picks the highest of multiple qualifying releases`() {
|
||||
val json = """
|
||||
[
|
||||
{ "tag_name": "v1.1.0", "draft": false, "prerelease": false,
|
||||
"assets": [ { "name": "KwabaTV.apk" } ] },
|
||||
{ "tag_name": "v1.2.0", "draft": false, "prerelease": false,
|
||||
"assets": [ { "name": "KwabaTV.apk" } ] }
|
||||
]
|
||||
""".trimIndent()
|
||||
val result = UpdateChecker.pickUpdate(json, "1.0.0")
|
||||
assertEquals("1.2.0", result?.version)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
plugins {
|
||||
id("com.android.application") version "8.5.2" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
# 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 `KeyEvent`s in `dispatchKeyEvent()`, re-dispatching real (not JS-simulated) `KeyEvent`s 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`**
|
||||
|
||||
```kotlin
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
rootProject.name = "KwabaTV"
|
||||
include(":app")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the root `build.gradle.kts`**
|
||||
|
||||
```kotlin
|
||||
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`**
|
||||
|
||||
```kotlin
|
||||
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
|
||||
<?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
|
||||
<?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
|
||||
<?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`:
|
||||
|
||||
```kotlin
|
||||
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.
|
||||
|
||||
```powershell
|
||||
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**
|
||||
|
||||
```powershell
|
||||
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**
|
||||
|
||||
```bash
|
||||
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**
|
||||
|
||||
```powershell
|
||||
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:
|
||||
|
||||
```xml
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:theme="@android:style/Theme.NoTitleBar.Fullscreen">
|
||||
```
|
||||
|
||||
Change to:
|
||||
|
||||
```xml
|
||||
<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**
|
||||
|
||||
```powershell
|
||||
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**
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```kotlin
|
||||
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**
|
||||
|
||||
```powershell
|
||||
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:
|
||||
```powershell
|
||||
& "$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**
|
||||
|
||||
```bash
|
||||
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.
|
||||
@@ -0,0 +1,652 @@
|
||||
# In-App Updates 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:** KwabaTV checks its public Gitea repo for a newer release on every launch and, with explicit user confirmation, downloads and installs it via Android's own system package installer.
|
||||
|
||||
**Architecture:** Fully self-contained in the kwabatv repo — no `kwaba-sync` server involvement, since `GGB/KwabaTV` is a public Gitea repo and its releases API needs no authentication. A pure, unit-tested version-comparison core (`UpdateChecker`) is wrapped by a real network fetch, then wired into `MainActivity` via a native `AlertDialog` and Android's `DownloadManager` + system package installer.
|
||||
|
||||
**Tech Stack:** Kotlin, Android base SDK only (`org.json`, `java.net.HttpURLConnection`, `android.app.DownloadManager` — no new runtime dependencies), JUnit4 + `org.json:json` as test-only dependencies for local unit tests.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- No AndroidX dependencies — matches the project's existing zero-dependency, WebView-only design (see `docs/superpowers/specs/2026-09-27-in-app-updates-design.md`). This is why the plan uses `DownloadManager` instead of a custom `FileProvider`.
|
||||
- Release tag convention: `vX.Y.Z` (e.g. `v1.1.0`). APK asset name is always exactly `KwabaTV.apk`.
|
||||
- Never install silently — Android's system package installer always requires an explicit user tap, by OS design. Nothing in this plan attempts to bypass that.
|
||||
- Update-check failures (network unreachable, malformed JSON, no qualifying release) are always silent — no error dialog, no toast. Just try again next launch.
|
||||
- `JAVA_HOME` for every Gradle command in this plan: `/c/Users/GGB/jdks/jdk-21.0.12.1+1` (Gradle 8.7 does not run on JDK 25, which is what Android Studio's bundled JBR ships).
|
||||
- Build/install verification throughout uses the existing project pattern: `cd /c/Projekte/kwabatv && JAVA_HOME="/c/Users/GGB/jdks/jdk-21.0.12.1+1" ./gradlew.bat <task>`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Pure version-comparison logic, fully unit-tested
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/build.gradle.kts` (add a `dependencies` block — none exists yet)
|
||||
- Create: `app/src/main/java/de/ggb/kwabatv/UpdateChecker.kt`
|
||||
- Create: `app/src/test/java/de/ggb/kwabatv/UpdateCheckerTest.kt` (new source set — `src/test/java` doesn't exist yet in this project, Gradle picks it up automatically once it exists alongside a test dependency)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `data class UpdateInfo(val version: String, val apkDownloadUrl: String)` — top-level in `UpdateChecker.kt`, package `de.ggb.kwabatv` (no import needed from `MainActivity.kt`, same package).
|
||||
- Produces: `object UpdateChecker` with `fun parseVersion(version: String): List<Int>?`, `fun compareVersions(a: List<Int>, b: List<Int>): Int`, `fun pickUpdate(releasesJson: String, currentVersion: String): UpdateInfo?`. Task 2 adds a fourth function, `checkForUpdate`, to this same object.
|
||||
|
||||
- [ ] **Step 1: Add test dependencies to `app/build.gradle.kts`**
|
||||
|
||||
Current file ends with:
|
||||
```kotlin
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add a `dependencies` block directly after the closing `}` of the `android { ... }` block (i.e. at the very end of the file):
|
||||
|
||||
```kotlin
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
// Android's org.json classes are stubbed out (throw at runtime) in plain JVM unit
|
||||
// tests unless a real implementation is on the test classpath - this is that
|
||||
// implementation, test-only, never shipped in the APK.
|
||||
testImplementation("org.json:json:20231013")
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create `UpdateChecker.kt` with stub implementations (so the test file below compiles, but fails on assertions)**
|
||||
|
||||
Create `app/src/main/java/de/ggb/kwabatv/UpdateChecker.kt`:
|
||||
|
||||
```kotlin
|
||||
package de.ggb.kwabatv
|
||||
|
||||
/** A qualifying update found on Gitea: display version and the APK's direct download URL. */
|
||||
data class UpdateInfo(val version: String, val apkDownloadUrl: String)
|
||||
|
||||
/**
|
||||
* Checks the public KwabaTV Gitea repo for a newer release. Fully self-contained - no
|
||||
* kwaba-sync server involvement, since GGB/KwabaTV is a public repo and its releases API
|
||||
* needs no authentication. See docs/superpowers/specs/2026-09-27-in-app-updates-design.md.
|
||||
*/
|
||||
object UpdateChecker {
|
||||
|
||||
fun parseVersion(version: String): List<Int>? = null
|
||||
|
||||
fun compareVersions(a: List<Int>, b: List<Int>): Int = 0
|
||||
|
||||
fun pickUpdate(releasesJson: String, currentVersion: String): UpdateInfo? = null
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Write the test file**
|
||||
|
||||
Create `app/src/test/java/de/ggb/kwabatv/UpdateCheckerTest.kt`:
|
||||
|
||||
```kotlin
|
||||
package de.ggb.kwabatv
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class UpdateCheckerTest {
|
||||
|
||||
@Test
|
||||
fun `parseVersion parses a valid three-part version`() {
|
||||
assertEquals(listOf(1, 2, 3), UpdateChecker.parseVersion("1.2.3"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseVersion rejects a two-part version`() {
|
||||
assertNull(UpdateChecker.parseVersion("1.0"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseVersion rejects a version with a v prefix`() {
|
||||
assertNull(UpdateChecker.parseVersion("v1.2.3"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseVersion rejects non-numeric input`() {
|
||||
assertNull(UpdateChecker.parseVersion("a.b.c"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compareVersions is positive when a is newer in the minor position`() {
|
||||
assertTrue(UpdateChecker.compareVersions(listOf(1, 1, 0), listOf(1, 0, 9)) > 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compareVersions is negative when a is older in the patch position`() {
|
||||
assertTrue(UpdateChecker.compareVersions(listOf(1, 0, 0), listOf(1, 0, 1)) < 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compareVersions is zero for equal versions`() {
|
||||
assertEquals(0, UpdateChecker.compareVersions(listOf(2, 3, 4), listOf(2, 3, 4)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pickUpdate returns the newer release when one qualifies`() {
|
||||
val json = """
|
||||
[
|
||||
{
|
||||
"tag_name": "v1.1.0",
|
||||
"draft": false,
|
||||
"prerelease": false,
|
||||
"assets": [ { "name": "KwabaTV.apk" } ]
|
||||
}
|
||||
]
|
||||
""".trimIndent()
|
||||
val result = UpdateChecker.pickUpdate(json, "1.0.0")
|
||||
assertEquals(
|
||||
UpdateInfo("1.1.0", "https://git.whodovoodoo.de/GGB/KwabaTV/releases/download/v1.1.0/KwabaTV.apk"),
|
||||
result
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pickUpdate returns null when the release is not newer`() {
|
||||
val json = """
|
||||
[
|
||||
{ "tag_name": "v1.0.0", "draft": false, "prerelease": false,
|
||||
"assets": [ { "name": "KwabaTV.apk" } ] }
|
||||
]
|
||||
""".trimIndent()
|
||||
assertNull(UpdateChecker.pickUpdate(json, "1.0.0"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pickUpdate skips draft releases`() {
|
||||
val json = """
|
||||
[
|
||||
{ "tag_name": "v9.9.9", "draft": true, "prerelease": false,
|
||||
"assets": [ { "name": "KwabaTV.apk" } ] }
|
||||
]
|
||||
""".trimIndent()
|
||||
assertNull(UpdateChecker.pickUpdate(json, "1.0.0"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pickUpdate skips prerelease releases`() {
|
||||
val json = """
|
||||
[
|
||||
{ "tag_name": "v9.9.9", "draft": false, "prerelease": true,
|
||||
"assets": [ { "name": "KwabaTV.apk" } ] }
|
||||
]
|
||||
""".trimIndent()
|
||||
assertNull(UpdateChecker.pickUpdate(json, "1.0.0"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pickUpdate skips a release with no matching apk asset`() {
|
||||
val json = """
|
||||
[
|
||||
{ "tag_name": "v1.1.0", "draft": false, "prerelease": false,
|
||||
"assets": [ { "name": "checksums.txt" } ] }
|
||||
]
|
||||
""".trimIndent()
|
||||
assertNull(UpdateChecker.pickUpdate(json, "1.0.0"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pickUpdate returns null for malformed json`() {
|
||||
assertNull(UpdateChecker.pickUpdate("not json", "1.0.0"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pickUpdate returns null when currentVersion itself is malformed`() {
|
||||
val json = """
|
||||
[
|
||||
{ "tag_name": "v1.1.0", "draft": false, "prerelease": false,
|
||||
"assets": [ { "name": "KwabaTV.apk" } ] }
|
||||
]
|
||||
""".trimIndent()
|
||||
assertNull(UpdateChecker.pickUpdate(json, "not-a-version"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pickUpdate picks the highest of multiple qualifying releases`() {
|
||||
val json = """
|
||||
[
|
||||
{ "tag_name": "v1.1.0", "draft": false, "prerelease": false,
|
||||
"assets": [ { "name": "KwabaTV.apk" } ] },
|
||||
{ "tag_name": "v1.2.0", "draft": false, "prerelease": false,
|
||||
"assets": [ { "name": "KwabaTV.apk" } ] }
|
||||
]
|
||||
""".trimIndent()
|
||||
val result = UpdateChecker.pickUpdate(json, "1.0.0")
|
||||
assertEquals("1.2.0", result?.version)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the tests and verify they fail**
|
||||
|
||||
Run: `cd /c/Projekte/kwabatv && JAVA_HOME="/c/Users/GGB/jdks/jdk-21.0.12.1+1" ./gradlew.bat testDebugUnitTest --tests "de.ggb.kwabatv.UpdateCheckerTest"`
|
||||
|
||||
Expected: build reaches the test task and reports FAILED, with most of the 13 tests failing (the stubs return `null`/`0` unconditionally, so any test expecting a real value fails). This confirms the tests actually exercise `UpdateChecker` rather than trivially passing.
|
||||
|
||||
- [ ] **Step 5: Replace the stub with the real implementation**
|
||||
|
||||
Replace the full contents of `app/src/main/java/de/ggb/kwabatv/UpdateChecker.kt` with:
|
||||
|
||||
```kotlin
|
||||
package de.ggb.kwabatv
|
||||
|
||||
import org.json.JSONArray
|
||||
|
||||
/** A qualifying update found on Gitea: display version and the APK's direct download URL. */
|
||||
data class UpdateInfo(val version: String, val apkDownloadUrl: String)
|
||||
|
||||
/**
|
||||
* Checks the public KwabaTV Gitea repo for a newer release. Fully self-contained - no
|
||||
* kwaba-sync server involvement, since GGB/KwabaTV is a public repo and its releases API
|
||||
* needs no authentication. See docs/superpowers/specs/2026-09-27-in-app-updates-design.md.
|
||||
*/
|
||||
object UpdateChecker {
|
||||
private const val RELEASES_API_URL = "https://git.whodovoodoo.de/api/v1/repos/GGB/KwabaTV/releases"
|
||||
private const val DOWNLOAD_BASE_URL = "https://git.whodovoodoo.de/GGB/KwabaTV/releases/download"
|
||||
private const val ASSET_NAME = "KwabaTV.apk"
|
||||
private val VERSION_RE = Regex("""^(\d+)\.(\d+)\.(\d+)$""")
|
||||
|
||||
/** Parses a bare "X.Y.Z" version string (no "v" prefix) into [major, minor, patch], or null if malformed. */
|
||||
fun parseVersion(version: String): List<Int>? {
|
||||
val m = VERSION_RE.matchEntire(version) ?: return null
|
||||
return m.groupValues.drop(1).map { it.toInt() }
|
||||
}
|
||||
|
||||
/** Positive if [a] is newer than [b], negative if older, 0 if equal. Both must be 3-element lists. */
|
||||
fun compareVersions(a: List<Int>, b: List<Int>): Int {
|
||||
for (i in 0..2) {
|
||||
val diff = a[i] - b[i]
|
||||
if (diff != 0) return diff
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the newest qualifying release from a Gitea releases API JSON array: not a draft,
|
||||
* not a prerelease, tag matches "vX.Y.Z", newer than [currentVersion], and has a
|
||||
* "KwabaTV.apk" asset. Returns null if none qualify or the JSON is malformed. The download
|
||||
* URL is built manually from tag + asset name (same approach as kwaba-sync's own
|
||||
* src/desktop-release.js) rather than trusting the API response's browser_download_url.
|
||||
*/
|
||||
fun pickUpdate(releasesJson: String, currentVersion: String): UpdateInfo? {
|
||||
val current = parseVersion(currentVersion) ?: return null
|
||||
val releases = try { JSONArray(releasesJson) } catch (e: Exception) { return null }
|
||||
|
||||
var best: Pair<List<Int>, UpdateInfo>? = null
|
||||
for (i in 0 until releases.length()) {
|
||||
val release = releases.optJSONObject(i) ?: continue
|
||||
if (release.optBoolean("draft", false) || release.optBoolean("prerelease", false)) continue
|
||||
|
||||
val tag = release.optString("tag_name", "")
|
||||
if (!tag.startsWith("v")) continue
|
||||
val version = parseVersion(tag.substring(1)) ?: continue
|
||||
if (compareVersions(version, current) <= 0) continue
|
||||
|
||||
val assets = release.optJSONArray("assets") ?: continue
|
||||
var hasApk = false
|
||||
for (j in 0 until assets.length()) {
|
||||
if (assets.optJSONObject(j)?.optString("name") == ASSET_NAME) { hasApk = true; break }
|
||||
}
|
||||
if (!hasApk) continue
|
||||
|
||||
if (best == null || compareVersions(version, best!!.first) > 0) {
|
||||
best = version to UpdateInfo(version.joinToString("."), "$DOWNLOAD_BASE_URL/$tag/$ASSET_NAME")
|
||||
}
|
||||
}
|
||||
return best?.second
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run the tests and verify they pass**
|
||||
|
||||
Run: `cd /c/Projekte/kwabatv && JAVA_HOME="/c/Users/GGB/jdks/jdk-21.0.12.1+1" ./gradlew.bat testDebugUnitTest --tests "de.ggb.kwabatv.UpdateCheckerTest"`
|
||||
|
||||
Expected: BUILD SUCCESSFUL, all 13 tests pass.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
cd /c/Projekte/kwabatv
|
||||
git add app/build.gradle.kts app/src/main/java/de/ggb/kwabatv/UpdateChecker.kt app/src/test/java/de/ggb/kwabatv/UpdateCheckerTest.kt
|
||||
git commit -m "Add unit-tested update-version comparison logic"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Real network fetch + version bump
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/src/main/java/de/ggb/kwabatv/UpdateChecker.kt` (append one function to the `object UpdateChecker` from Task 1)
|
||||
- Modify: `app/build.gradle.kts:14-15` (version bump)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `UpdateChecker.pickUpdate(releasesJson: String, currentVersion: String): UpdateInfo?` (Task 1).
|
||||
- Produces: `UpdateChecker.checkForUpdate(currentVersion: String): UpdateInfo?` — does a real network call, returns null on any failure. Task 3's `MainActivity` calls this directly.
|
||||
|
||||
- [ ] **Step 1: Confirm the real Gitea API response shape matches what `pickUpdate` expects**
|
||||
|
||||
Run: `curl -s "https://git.whodovoodoo.de/api/v1/repos/GGB/KwabaTV/releases" | head -c 1000`
|
||||
|
||||
Expected: a JSON array (possibly empty, `[]`, if no releases are published yet — that's fine, it just means `checkForUpdate` will correctly return null against real data). If any releases exist, confirm each object has `tag_name`, `draft`, `prerelease`, and `assets` (an array of objects with `name`) — these are the exact fields `pickUpdate` reads. No code changes in this step, just verification against the live API before wiring up the caller.
|
||||
|
||||
- [ ] **Step 2: Add `checkForUpdate` to `UpdateChecker.kt`**
|
||||
|
||||
Add these imports to the top of `app/src/main/java/de/ggb/kwabatv/UpdateChecker.kt` (after `import org.json.JSONArray`):
|
||||
|
||||
```kotlin
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
```
|
||||
|
||||
Add this function inside `object UpdateChecker { ... }`, after `pickUpdate`:
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* Fetches the releases list and returns the newest qualifying update, or null - including
|
||||
* on any failure (network unreachable, non-200 response, malformed body). An update check
|
||||
* is a nice-to-have; it must never be able to crash or visibly disrupt normal app use.
|
||||
*/
|
||||
fun checkForUpdate(currentVersion: String): UpdateInfo? {
|
||||
return try {
|
||||
val connection = URL(RELEASES_API_URL).openConnection() as HttpURLConnection
|
||||
connection.connectTimeout = 8000
|
||||
connection.readTimeout = 8000
|
||||
connection.requestMethod = "GET"
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
if (connection.responseCode != 200) return null
|
||||
val body = connection.inputStream.bufferedReader().use { it.readText() }
|
||||
pickUpdate(body, currentVersion)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Bump the app version**
|
||||
|
||||
In `app/build.gradle.kts`, current lines 14-15:
|
||||
|
||||
```kotlin
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```kotlin
|
||||
versionCode = 2
|
||||
versionName = "1.1.0"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify the project still builds**
|
||||
|
||||
Run: `cd /c/Projekte/kwabatv && JAVA_HOME="/c/Users/GGB/jdks/jdk-21.0.12.1+1" ./gradlew.bat assembleDebug`
|
||||
|
||||
Expected: BUILD SUCCESSFUL. This confirms `checkForUpdate` compiles correctly against the real `HttpURLConnection`/`URL` APIs — it isn't unit-tested (real network I/O), so this compile check plus Task 3's real-device test are its verification.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /c/Projekte/kwabatv
|
||||
git add app/src/main/java/de/ggb/kwabatv/UpdateChecker.kt app/build.gradle.kts
|
||||
git commit -m "Add real Gitea release fetch; bump version to 1.1.0"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Wire into MainActivity — dialog, permission gate, download, install
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/src/main/AndroidManifest.xml:4` (new permission)
|
||||
- Modify: `app/src/main/java/de/ggb/kwabatv/MainActivity.kt` (imports, new fields, `onCreate`/`onDestroy` changes, new private methods)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `UpdateChecker.checkForUpdate(currentVersion: String): UpdateInfo?` (Task 2), `UpdateInfo(version: String, apkDownloadUrl: String)` (Task 1).
|
||||
|
||||
- [ ] **Step 1: Add the install-packages permission**
|
||||
|
||||
In `app/src/main/AndroidManifest.xml`, current line 4:
|
||||
|
||||
```xml
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
```
|
||||
|
||||
Add directly after it:
|
||||
|
||||
```xml
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add new imports to `MainActivity.kt`**
|
||||
|
||||
Current imports:
|
||||
|
||||
```kotlin
|
||||
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
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```kotlin
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.app.DownloadManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.SystemClock
|
||||
import android.provider.Settings
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
import android.webkit.PermissionRequest
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add new fields**
|
||||
|
||||
Current field declarations:
|
||||
|
||||
```kotlin
|
||||
private lateinit var webView: WebView
|
||||
private var fullscreenView: View? = null
|
||||
private var currentPath: String = "/tv"
|
||||
private var lastThrottledSeekAt = 0L
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```kotlin
|
||||
private lateinit var webView: WebView
|
||||
private var fullscreenView: View? = null
|
||||
private var currentPath: String = "/tv"
|
||||
private var lastThrottledSeekAt = 0L
|
||||
private var pendingDownloadId: Long = -1
|
||||
private var receiverRegistered = false
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Trigger the update check from `onCreate`**
|
||||
|
||||
Current end of `onCreate`:
|
||||
|
||||
```kotlin
|
||||
webView.loadUrl(BuildConfig.WEBSITE_URL)
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```kotlin
|
||||
webView.loadUrl(BuildConfig.WEBSITE_URL)
|
||||
checkForUpdateInBackground()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Clean up the download receiver in `onDestroy`**
|
||||
|
||||
Current:
|
||||
|
||||
```kotlin
|
||||
override fun onDestroy() {
|
||||
webView.destroy()
|
||||
super.onDestroy()
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```kotlin
|
||||
override fun onDestroy() {
|
||||
webView.destroy()
|
||||
if (receiverRegistered) { unregisterReceiver(downloadReceiver); receiverRegistered = false }
|
||||
super.onDestroy()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Add the update flow methods**
|
||||
|
||||
Add this block right after `onDestroy()` (before `private fun isRoomList()...`):
|
||||
|
||||
```kotlin
|
||||
/* ---------------------------------------------------------- In-App-Update
|
||||
Prueft bei jedem Start das oeffentliche KwabaTV-Gitea-Repo auf ein neueres
|
||||
Release (siehe UpdateChecker.kt) und bietet es ueber Androids eigenen
|
||||
Paket-Installer an - nie ohne Bestaetigung, das Betriebssystem selbst
|
||||
erlaubt keine stille Installation. Ein fehlgeschlagener Check ist immer
|
||||
lautlos: kein Fehlerdialog, einfach beim naechsten Start erneut versuchen. */
|
||||
|
||||
private fun checkForUpdateInBackground() {
|
||||
Thread {
|
||||
val update = UpdateChecker.checkForUpdate(BuildConfig.VERSION_NAME)
|
||||
if (update != null) runOnUiThread { showUpdateDialog(update) }
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun showUpdateDialog(update: UpdateInfo) {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("Update verfügbar")
|
||||
.setMessage("KwabaTV v${update.version} ist verfügbar.")
|
||||
.setPositiveButton("Aktualisieren") { _, _ -> startUpdate(update) }
|
||||
.setNegativeButton("Später", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
/* "Unbekannte Apps installieren" ist fuer neue Apps ab Android 8 (API 26)
|
||||
pro App einzeln zu erlauben - ohne das wuerde der Installations-Intent
|
||||
unten stillschweigend nichts tun. Statt automatisch nach der Rueckkehr
|
||||
aus den Einstellungen weiterzumachen (mehr Zustand, mehr Fehlerquellen),
|
||||
muss der Nutzer "Aktualisieren" nach dem Erlauben schlicht erneut
|
||||
druecken - siehe Design-Spec. */
|
||||
private fun startUpdate(update: UpdateInfo) {
|
||||
val canInstall = Build.VERSION.SDK_INT < Build.VERSION_CODES.O || packageManager.canRequestPackageInstalls()
|
||||
if (!canInstall) {
|
||||
startActivity(Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName")))
|
||||
return
|
||||
}
|
||||
downloadApk(update.apkDownloadUrl)
|
||||
}
|
||||
|
||||
private fun downloadApk(url: String) {
|
||||
val request = DownloadManager.Request(Uri.parse(url))
|
||||
.setTitle("KwabaTV Update")
|
||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
.setDestinationInExternalFilesDir(this, null, "KwabaTV-update.apk")
|
||||
val downloadManager = getSystemService(DOWNLOAD_SERVICE) as DownloadManager
|
||||
pendingDownloadId = downloadManager.enqueue(request)
|
||||
// Ab Android 13 (API 33) muss jeder dynamisch registrierte Receiver
|
||||
// explizit als (nicht-)exportiert markiert sein, sonst wirft
|
||||
// registerReceiver() eine SecurityException.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
registerReceiver(downloadReceiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||
registerReceiver(downloadReceiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE))
|
||||
}
|
||||
receiverRegistered = true
|
||||
}
|
||||
|
||||
private val downloadReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) != pendingDownloadId) return
|
||||
if (receiverRegistered) { unregisterReceiver(this); receiverRegistered = false }
|
||||
val downloadManager = getSystemService(DOWNLOAD_SERVICE) as DownloadManager
|
||||
// getUriForDownloadedFile() returns null for a failed download - that failure is
|
||||
// silent for now too, matching the update check's own error handling (see spec).
|
||||
val uri = downloadManager.getUriForDownloadedFile(pendingDownloadId) ?: return
|
||||
installApk(uri)
|
||||
}
|
||||
}
|
||||
|
||||
private fun installApk(uri: Uri) {
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Verify the project builds**
|
||||
|
||||
Run: `cd /c/Projekte/kwabatv && JAVA_HOME="/c/Users/GGB/jdks/jdk-21.0.12.1+1" ./gradlew.bat assembleDebug`
|
||||
|
||||
Expected: BUILD SUCCESSFUL.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
cd /c/Projekte/kwabatv
|
||||
git add app/src/main/AndroidManifest.xml app/src/main/java/de/ggb/kwabatv/MainActivity.kt
|
||||
git commit -m "Wire update check into MainActivity: dialog, download, install"
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Publish a real test release on the KwabaTV Gitea repo**
|
||||
|
||||
This step requires the current `app-debug.apk` (built in Step 7) to be renamed `KwabaTV.apk` and attached to a real Gitea release tagged one version below what's about to be tested, so the *installed* app is older than the *published* release and the update check has something real to find.
|
||||
|
||||
- Install the just-built APK (still `versionName "1.1.0"`) normally first via `adb install -r`, confirm it runs.
|
||||
- Temporarily edit `app/build.gradle.kts` to `versionName = "1.0.0"` (simulating "an older version is installed"), rebuild (`assembleDebug`), install that older-numbered build via `adb install -r` so the device's installed version is genuinely older than the release you're about to publish.
|
||||
- Revert `app/build.gradle.kts` back to `versionName = "1.1.0"` (do not commit the temporary `1.0.0` edit).
|
||||
- Rebuild (`assembleDebug`), rename the resulting APK to `KwabaTV.apk`, and publish it as a Gitea release on `GGB/KwabaTV` tagged `v1.1.0` (via the Gitea web UI: Releases → New Release → tag `v1.1.0`, attach `KwabaTV.apk`, publish — not a draft, not a prerelease).
|
||||
|
||||
- [ ] **Step 10: Verify the full flow on the real Fire TV Stick**
|
||||
|
||||
- Relaunch the app on-device (already showing `1.0.0` per the previous step). Confirm the `AlertDialog` appears: "KwabaTV v1.1.0 ist verfügbar."
|
||||
- Press "Aktualisieren". First time through, expect Android to redirect to the "install unknown apps" settings screen for KwabaTV (unless already granted from earlier sideloading in this project) — grant it, return to the app, press "Aktualisieren" again.
|
||||
- Confirm `DownloadManager`'s notification shows the download progressing, then Android's own package installer screen appears automatically once it completes.
|
||||
- Tap "Install" in the system installer. Confirm the app updates in place and relaunches showing `versionName 1.1.0`.
|
||||
- Relaunch the app once more with no newer release published: confirm no dialog appears (already current).
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
- **Spec coverage:** release convention (Task 1/2), version bump (Task 2), background check on launch (Task 3 Step 4), native `AlertDialog` with DPAD-navigable buttons (Task 3, uses Android's default focusable dialog buttons — no custom styling needed), `DownloadManager`-based download avoiding AndroidX (Task 3), permission gate + `ACTION_MANAGE_UNKNOWN_APP_SOURCES` redirect (Task 3), silent error handling throughout (Task 1-2's `checkForUpdate`/`pickUpdate`, Task 3's receiver), real-device end-to-end verification (Task 3 Steps 9-10). All covered.
|
||||
- **Placeholder scan:** none found — every step has complete, concrete code or an exact command.
|
||||
- **Type consistency:** `UpdateInfo(version, apkDownloadUrl)` used identically in Task 1 (definition + tests), Task 2 (`checkForUpdate` return type), Task 3 (`showUpdateDialog`/`startUpdate` parameter). `UpdateChecker.checkForUpdate(currentVersion: String): UpdateInfo?` signature matches between Task 2's definition and Task 3's call site (`UpdateChecker.checkForUpdate(BuildConfig.VERSION_NAME)`).
|
||||
@@ -0,0 +1,158 @@
|
||||
# In-App Updates — Design
|
||||
|
||||
Date: 2026-09-27
|
||||
Status: approved, pending implementation plan
|
||||
|
||||
## Background
|
||||
|
||||
KwabaTV has no update mechanism today — every new build requires a manual
|
||||
`adb install -r` sideload from a development machine. That's fine for this
|
||||
session's iteration pace, but not sustainable once the app is "done" and
|
||||
just sitting on a Fire TV Stick: any future fix requires physically
|
||||
reconnecting ADB.
|
||||
|
||||
Kwaba Sync's own desktop Electron app (`desktop/`, in the `kwaba-sync`
|
||||
repo) already solved an equivalent problem: the server proxies the latest
|
||||
GitHub-style release from Gitea (`src/desktop-release.js`), gated behind a
|
||||
login because that repository is private, and the Electron app polls it,
|
||||
shows an in-window update banner, and never installs without an explicit
|
||||
click.
|
||||
|
||||
KwabaTV's own Gitea repo (`GGB/KwabaTV`) is public — confirmed via its
|
||||
Gitea API (`visibility":"public"`, releases endpoint returns 200
|
||||
unauthenticated). That removes the reason the desktop app's design needed a
|
||||
server-side proxy at all (private-repo auth). This design is intentionally
|
||||
simpler than desktop's as a result: no `kwaba-sync` involvement, no
|
||||
server-side changes, no coordination needed with that repo's owner. It
|
||||
mirrors desktop's *behavior* (check on launch, never install silently,
|
||||
graceful failure) without mirroring its *architecture*.
|
||||
|
||||
## Goal
|
||||
|
||||
The app checks Gitea for a newer release on every launch, and — if one
|
||||
exists — offers to download and install it, entirely on-device.
|
||||
|
||||
## Release convention
|
||||
|
||||
- `versionName` in `app/build.gradle.kts` moves from the current `"1.0"`
|
||||
to a proper 3-part semver (e.g. `"1.1.0"`) as part of this work, since
|
||||
the comparison logic below assumes major/minor/patch on both sides.
|
||||
- Tag format: `vX.Y.Z` (e.g. `v1.1.0`), matching `versionName`.
|
||||
- Exactly one APK asset per release, always named `KwabaTV.apk`, so the
|
||||
app never has to guess which asset to download.
|
||||
- Releases are published manually for now (no CI in this repo yet).
|
||||
Drafts and pre-releases are ignored by the version check.
|
||||
|
||||
## Components
|
||||
|
||||
All new code lives in kwabatv; nothing else changes outside it.
|
||||
|
||||
**`UpdateChecker.kt`** — a small, self-contained class with one entry
|
||||
point, e.g. `checkForUpdate(context, onAvailable: (UpdateInfo) -> Unit)`.
|
||||
On a background thread, it does a plain HTTP GET to
|
||||
`https://git.whodovoodoo.de/api/v1/repos/GGB/KwabaTV/releases`, picks the
|
||||
highest-versioned non-draft, non-prerelease release whose tag matches
|
||||
`vX.Y.Z`, and compares it numerically (major/minor/patch) against
|
||||
`BuildConfig.VERSION_NAME`. If the release is newer, it calls back with
|
||||
`{ version, apkDownloadUrl }` on the main thread. Any failure along the
|
||||
way (network unreachable, non-200, malformed JSON, no matching release) is
|
||||
swallowed silently — no error UI, no retry logic beyond "try again next
|
||||
launch."
|
||||
|
||||
**`MainActivity.onCreate()`** calls `UpdateChecker.checkForUpdate(...)`
|
||||
right after `webView.loadUrl(BuildConfig.WEBSITE_URL)`, so the check never
|
||||
delays getting to content. If the callback fires, it shows a native
|
||||
`AlertDialog`:
|
||||
|
||||
> **KwabaTV v1.1.0 ist verfügbar**
|
||||
> [Aktualisieren] [Später]
|
||||
|
||||
Android's default dialog buttons are focusable and DPAD-navigable with no
|
||||
extra work. "Später" just dismisses; the same check runs again next
|
||||
launch, with no persisted "skip this version" state.
|
||||
|
||||
**Download + install**, triggered by "Aktualisieren":
|
||||
|
||||
- Uses `android.app.DownloadManager` (a base-SDK system service — no
|
||||
AndroidX needed, keeping the app's existing zero-dependency footprint)
|
||||
to download the APK asset directly from Gitea's public release URL.
|
||||
- Registers a `BroadcastReceiver` for
|
||||
`DownloadManager.ACTION_DOWNLOAD_COMPLETE` to know when the download
|
||||
finishes.
|
||||
- On completion, calls `DownloadManager.getUriForDownloadedFile(id)` — this
|
||||
returns a ready-to-use `content://` URI with no custom `FileProvider`
|
||||
setup required (the earlier draft of this design needed one, which
|
||||
would have pulled in an AndroidX dependency; `DownloadManager` avoids
|
||||
that entirely).
|
||||
- Fires `Intent(ACTION_VIEW)` with that URI, MIME type
|
||||
`application/vnd.android.package-archive`, and
|
||||
`FLAG_GRANT_READ_URI_PERMISSION`, which opens Android's own system
|
||||
package installer. The user taps "Install" themselves inside that
|
||||
system UI — Android does not allow silent unattended installs, by OS
|
||||
design, regardless of permissions granted.
|
||||
|
||||
**First-run permission gate:** before firing the install intent, check
|
||||
`packageManager.canRequestPackageInstalls()`. If false (first time ever
|
||||
installing an update), send the user to
|
||||
`Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES` for this package instead, so
|
||||
they can grant "install unknown apps" for KwabaTV specifically. They then
|
||||
press "Aktualisieren" again to retry.
|
||||
|
||||
**New manifest entries:**
|
||||
- `<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />`
|
||||
- No new `<provider>` entry (avoided by using `DownloadManager`, see
|
||||
above).
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
App launch
|
||||
→ webView.loadUrl(WEBSITE_URL) [not blocked by the check]
|
||||
→ UpdateChecker (background thread)
|
||||
GET git.whodovoodoo.de/.../releases
|
||||
→ newer release found?
|
||||
no → nothing happens
|
||||
yes → AlertDialog on main thread
|
||||
"Später" → nothing further this launch
|
||||
"Aktualisieren" → canRequestPackageInstalls()?
|
||||
no → Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES
|
||||
yes → DownloadManager.enqueue(apk)
|
||||
→ ACTION_DOWNLOAD_COMPLETE
|
||||
→ ACTION_VIEW install intent
|
||||
→ system installer UI (user taps Install)
|
||||
```
|
||||
|
||||
## Error handling
|
||||
|
||||
Every failure mode in the check itself (DNS/network failure, non-200,
|
||||
malformed JSON, no releases, no tag matching `vX.Y.Z`) results in no
|
||||
dialog and no visible change — identical to a launch where the app is
|
||||
already current. This matches desktop's graceful-degradation philosophy:
|
||||
an update check is a nice-to-have, never something that should be able to
|
||||
block or degrade normal app use.
|
||||
|
||||
Download failures (`DownloadManager` reports a failed status) are also
|
||||
silent for now — the user can just press "Aktualisieren" again next
|
||||
launch, or manually sideload as a fallback (this doesn't remove that
|
||||
option). A visible failure toast is a reasonable future improvement, not
|
||||
required for the first version of this feature.
|
||||
|
||||
## Testing
|
||||
|
||||
This feature cannot be meaningfully unit-tested in isolation — the
|
||||
interesting behavior is entirely about real network calls, real Android
|
||||
permission state, and the real system package installer. It will be
|
||||
verified end-to-end on the real Fire TV Stick used throughout this
|
||||
project: publish an actual test release on the `GGB/KwabaTV` Gitea repo,
|
||||
then run the full check → dialog → download → permission-gate →
|
||||
install flow on-device, the same hands-on verification approach used for
|
||||
every other feature this session.
|
||||
|
||||
## Out of scope (for this pass)
|
||||
|
||||
- Any `kwaba-sync` server changes (Option B from the design discussion —
|
||||
hosting the APK for discovery from the Kwaba website's lobby — is a
|
||||
separate, later concern, not required for in-app updates to work).
|
||||
- CI/automated release publishing.
|
||||
- Periodic re-checks while the app stays open (check is launch-only).
|
||||
- "Skip this version" / persisted dismissal state.
|
||||
@@ -0,0 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx2048m
|
||||
android.useAndroidX=false
|
||||
kotlin.code.style=official
|
||||
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,249 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
@@ -0,0 +1,92 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,15 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
rootProject.name = "KwabaTV"
|
||||
include(":app")
|
||||