Add in-app updates implementation plan
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHgWjmEJXsJEi1EVAoTp4K
This commit is contained in:
@@ -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)`).
|
||||
Reference in New Issue
Block a user