Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
647cbf6a9a | ||
|
|
49061f3d32 | ||
|
|
29065f8c29 | ||
|
|
d67c2a54de | ||
|
|
286872c972 | ||
|
|
5cc18d760d | ||
|
|
ef3648ad14 |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "de.ggb.kwabatv"
|
||||
minSdk = 21
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
versionCode = 2
|
||||
versionName = "1.1.0"
|
||||
buildConfigField("String", "WEBSITE_URL", "\"https://kwaba.whodovoodoo.pasteaf.de/tv\"")
|
||||
}
|
||||
|
||||
@@ -35,3 +35,11 @@ android {
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
<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="@drawable/ic_launcher_art"
|
||||
android:banner="@drawable/banner_tv_art"
|
||||
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">
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
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
|
||||
@@ -49,6 +57,8 @@ class MainActivity : Activity() {
|
||||
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)
|
||||
@@ -84,13 +94,92 @@ class MainActivity : Activity() {
|
||||
}
|
||||
|
||||
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/")
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
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,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,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.
|
||||