From 2fd4d978d0f04eefefb46ff590e317a4a2cd2862 Mon Sep 17 00:00:00 2001 From: Honey Date: Wed, 2 Sep 2026 00:46:11 +0200 Subject: [PATCH] feat(android): in-app updater (backend/Gitea APK), auto-bump versionCode The app checks {backend}/api/apk/info on launch and, if a newer versionCode than BuildConfig.VERSION_CODE is offered, shows an update dialog that downloads {backend}/api/apk into a single wiped-each-time cache file and hands it to the system installer (FileProvider + REQUEST_INSTALL_PACKAGES). No APK hoarding; in-place install preserves data (stable signing key needed). - backend /api/apk/info now returns versionCode (parsed from the release body). - publish-apk.mjs auto-increments versionCode, sets versionName, and writes versionCode/versionName into the Gitea release body. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 8 ++ android/app/src/main/AndroidManifest.xml | 13 +++ .../com/novasonic/app/data/AppUpdater.kt | 96 +++++++++++++++++++ .../com/novasonic/app/ui/AppNavigation.kt | 23 +++++ android/app/src/main/res/xml/file_paths.xml | 5 + backend/novasonic-server.js | 11 ++- scripts/publish-apk.mjs | 33 ++++--- 7 files changed, 175 insertions(+), 14 deletions(-) create mode 100644 android/app/src/main/kotlin/com/novasonic/app/data/AppUpdater.kt create mode 100644 android/app/src/main/res/xml/file_paths.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index afae851..08bd528 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,14 @@ like Android), concert events become **reliable across web/Android**, several An **YouTube downloads are recovered** after they broke on two independent fronts. ### Added +- **In-app updater (Android)** β€” on launch the app asks the backend (`/api/apk/info`) whether a newer + **versionCode** than this build is available and, if so, offers "Laden & installieren": it downloads + the APK from `/api/apk` (which proxies the fixed Gitea release) into a single cache file, then hands + it to the system installer. No APK pile-up (the cache file is wiped before each download); an update + keeps app data / offline downloads (same package + signature = in-place install). The user still + confirms the system install and grants "unknown apps" once β€” Android requires that for sideloading. + `publish-apk` now **auto-increments `versionCode`** each build and writes it into the release so the + check is a reliable integer compare (commit the bumped `build.gradle.kts`). - **Android APK download in Settings (Web)** β€” a "πŸ€– Android App" section in Settings offers the latest APK so co-users self-serve instead of the maintainer hand-delivering builds. The button appears only when an APK is available and shows version/size/date. The backend serves it diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6ec18a9..d9586a4 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -8,6 +8,8 @@ + + + + + + + (null) + val available: StateFlow = _available + + private val _downloading = MutableStateFlow(false) + val downloading: StateFlow = _downloading + + // Versions the user waved away this session β€” don't nag again until app restart. + @Volatile private var dismissedCode: Int = -1 + + suspend fun check(backendUrl: String) { + if (backendUrl.isBlank()) return + val info = withContext(Dispatchers.IO) { + runCatching { + val c = (URL("${backendUrl.trimEnd('/')}/api/apk/info").openConnection() as HttpURLConnection) + .apply { connectTimeout = 8000; readTimeout = 8000 } + c.inputStream.bufferedReader().use { it.readText() } + .let { json.decodeFromString(it) } + }.getOrNull() + } ?: return + val vc = info.versionCode ?: return // no reliable integer β†’ don't prompt + if (info.available && vc > BuildConfig.VERSION_CODE && vc != dismissedCode) { + _available.value = info + } + } + + fun dismiss() { + dismissedCode = _available.value?.versionCode ?: dismissedCode + _available.value = null + } + + suspend fun downloadAndInstall(context: Context, backendUrl: String, onError: (String) -> Unit) { + if (_downloading.value || backendUrl.isBlank()) return + _downloading.value = true + try { + val apk = withContext(Dispatchers.IO) { + val dir = File(context.cacheDir, "updates").apply { mkdirs(); listFiles()?.forEach { it.delete() } } + val out = File(dir, "NovaSonic-update.apk") + val c = (URL("${backendUrl.trimEnd('/')}/api/apk").openConnection() as HttpURLConnection) + .apply { connectTimeout = 15000; readTimeout = 30000; instanceFollowRedirects = true } + if (c.responseCode !in 200..299) throw RuntimeException("HTTP ${c.responseCode}") + c.inputStream.use { input -> out.outputStream().use { input.copyTo(it) } } + out + } + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk) + context.startActivity( + Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/vnd.android.package-archive") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) + } + ) + _available.value = null // installer takes over + } catch (e: Exception) { + onError(e.message ?: "Download fehlgeschlagen") + } finally { + _downloading.value = false + } + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt index 9323d84..379f245 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt @@ -80,6 +80,29 @@ fun AppNavigation() { val appSettings by repo.appSettingsFlow().collectAsStateWithLifecycle(PrefsStore.AppSettings()) val lastPlayedSong by repo.lastPlayedSongFlow().collectAsStateWithLifecycle(null) + // ── In-app updater β€” offer the newer APK the backend advertises ───────────── + val updateInfo by com.novasonic.app.data.AppUpdater.available.collectAsStateWithLifecycle() + val updating by com.novasonic.app.data.AppUpdater.downloading.collectAsStateWithLifecycle() + val updateScope = rememberCoroutineScope() + LaunchedEffect(appSettings.backendUrl) { com.novasonic.app.data.AppUpdater.check(appSettings.backendUrl) } + updateInfo?.let { info -> + AlertDialog( + onDismissRequest = { com.novasonic.app.data.AppUpdater.dismiss() }, + title = { Text("Update verfΓΌgbar") }, + text = { Text("NovaSonic ${info.version ?: ""} ist verfΓΌgbar. Jetzt laden und installieren? Deine Downloads und Einstellungen bleiben erhalten.") }, + confirmButton = { + TextButton(enabled = !updating, onClick = { + updateScope.launch { + com.novasonic.app.data.AppUpdater.downloadAndInstall(context, appSettings.backendUrl) { msg -> + android.widget.Toast.makeText(context, msg, android.widget.Toast.LENGTH_LONG).show() + } + } + }) { Text(if (updating) "LΓ€dt…" else "Laden & installieren") } + }, + dismissButton = { TextButton(onClick = { com.novasonic.app.data.AppUpdater.dismiss() }) { Text("SpΓ€ter") } }, + ) + } + // 30s preview (app-level, so its control bar survives navigation). Starting real playback // cancels any active preview so the two don't overlap. val previewState by app.previewController.state.collectAsStateWithLifecycle() diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..38f818b --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index 5183ae5..307109d 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -508,10 +508,14 @@ async function _giteaApkAsset() { const rel = await _giteaApiJson(`/releases/tags/${encodeURIComponent(GITEA_APK_TAG)}`); const asset = (rel.assets || []).find(a => /\.apk$/i.test(a.name || '')); if (!asset) return null; + // The publish script writes "versionCode=NN" into the release body so the in-app updater has a + // reliable integer to compare against BuildConfig.VERSION_CODE. + const vcMatch = /versionCode\s*[=:]\s*(\d+)/i.exec(rel.body || ''); return { size: asset.size, url: asset.browser_download_url, version: rel.name || rel.tag_name || null, + versionCode: vcMatch ? parseInt(vcMatch[1], 10) : null, updatedAt: new Date(asset.created_at || rel.published_at || Date.now()).getTime(), }; } @@ -548,13 +552,14 @@ function _streamApkFromUrl(urlStr, res, redirectsLeft = 3) { app.get('/api/apk/info', async (req, res) => { try { const a = await _giteaApkAsset(); - if (a) return res.json({ available: true, size: a.size, updatedAt: a.updatedAt, version: a.version, source: 'gitea' }); + if (a) return res.json({ available: true, size: a.size, updatedAt: a.updatedAt, version: a.version, versionCode: a.versionCode, source: 'gitea' }); } catch { /* fall through to local */ } fs.stat(APK_PATH, (err, st) => { if (err) return res.json({ available: false }); - let version = null; + let version = null, versionCode = null; try { version = fs.readFileSync(APK_VER_PATH, 'utf8').trim() || null; } catch {} - res.json({ available: true, size: st.size, updatedAt: st.mtimeMs, version, source: 'local' }); + try { versionCode = parseInt(fs.readFileSync(path.join(CONFIG.dataPath, 'novasonic.versioncode.txt'), 'utf8').trim(), 10) || null; } catch {} + res.json({ available: true, size: st.size, updatedAt: st.mtimeMs, version, versionCode, source: 'local' }); }); }); diff --git a/scripts/publish-apk.mjs b/scripts/publish-apk.mjs index 6e9d2bd..05b23fc 100644 --- a/scripts/publish-apk.mjs +++ b/scripts/publish-apk.mjs @@ -17,7 +17,7 @@ // install over each other. Add a keystore + signingConfig later to switch APK_GRADLE_TASK=assembleRelease. import { spawnSync } from 'node:child_process'; -import { readFileSync, existsSync } from 'node:fs'; +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join, resolve } from 'node:path'; @@ -45,13 +45,24 @@ if (!GITEA_URL || !GITEA_REPO || !GITEA_TOKEN) const API = `${GITEA_URL}/api/v1/repos/${GITEA_REPO}`; const authHeaders = { Authorization: `token ${GITEA_TOKEN}` }; -// ── version name from the Gradle config (for the release title) ────────────── -let version = new Date().toISOString().slice(0, 10); -try { - const g = readFileSync(join(root, 'android', 'app', 'build.gradle.kts'), 'utf8'); - const m = g.match(/versionName\s*=\s*"([^"]+)"/); - if (m) version = m[1]; -} catch {} +// ── bump versionCode + read versionName from the Gradle config ─────────────── +// The in-app updater compares versionCode, so every published build MUST increment it β€” we do that +// here automatically (pass --version X.Y to also set the versionName). Commit the changed +// build.gradle.kts afterwards so the repo reflects what's live. +const gradlePath = join(root, 'android', 'app', 'build.gradle.kts'); +let gradle = readFileSync(gradlePath, 'utf8'); +const curCode = parseInt(gradle.match(/versionCode\s*=\s*(\d+)/)?.[1] || '0', 10); +const versionCode = curCode + 1; +const versionArg = process.argv.slice(2).find(a => a.startsWith('--version='))?.split('=')[1] + || (process.argv.includes('--version') ? process.argv[process.argv.indexOf('--version') + 1] : null); +let version = versionArg || gradle.match(/versionName\s*=\s*"([^"]+)"/)?.[1] || `0.${versionCode}`; +gradle = gradle + .replace(/versionCode\s*=\s*\d+/, `versionCode = ${versionCode}`) + .replace(/versionName\s*=\s*"[^"]*"/, `versionName = "${version}"`); +writeFileSync(gradlePath, gradle); +console.log(`β–Ά versionCode β†’ ${versionCode}, versionName β†’ ${version} (build.gradle.kts danach committen!)`); + +const relBody = `versionCode=${versionCode}\nversionName=${version}`; // ── 1) build ───────────────────────────────────────────────────────────────── const isWin = process.platform === 'win32'; @@ -86,7 +97,7 @@ let rel; const cr = await api('/releases', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tag_name: TAG, target: 'main', name: `NovaSonic ${version}`, body: 'NovaSonic Android App β€” neueste Version.' }), + body: JSON.stringify({ tag_name: TAG, target: 'main', name: `NovaSonic ${version}`, body: relBody }), }); if (!cr.ok) die(`Release anlegen fehlgeschlagen: ${cr.status} ${await cr.text()}`); rel = await cr.json(); @@ -108,11 +119,11 @@ form.append('attachment', new Blob([apkBytes], { type: 'application/vnd.android. const up = await api(`/releases/${rel.id}/assets?name=NovaSonic.apk`, { method: 'POST', body: form }); if (!up.ok) die(`Upload fehlgeschlagen: ${up.status} ${await up.text()}`); -// keep the release title in sync with the version +// keep the release title + versionCode body in sync await api(`/releases/${rel.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: `NovaSonic ${version}` }), + body: JSON.stringify({ name: `NovaSonic ${version}`, body: relBody }), }).catch(() => {}); console.log(`\nβœ” NovaSonic ${version} hochgeladen β†’ Release "${TAG}". Der Web-Button serviert jetzt diese APK.`);