Add in-app updates design spec
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,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.
|
||||
Reference in New Issue
Block a user