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 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 4.8 committed 2026-09-02 00:46:11 +02:00
1 parent 845f125d76
commit 2fd4d978d0
7 files changed
+175 -14

No files matched your search

+8
View File
@@ -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. **YouTube downloads are recovered** after they broke on two independent fronts.
### Added ### 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 - **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 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 appears only when an APK is available and shows version/size/date. The backend serves it
+13
View File
@@ -8,6 +8,8 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/> <uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<!-- In-app updater: install a downloaded APK (user still confirms the install) -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<application <application
android:name=".NovaSonicApp" android:name=".NovaSonicApp"
@@ -44,6 +46,17 @@
</service> </service>
<!-- FileProvider — hands the downloaded update APK to the system installer -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"/>
</provider>
<!-- Android Auto --> <!-- Android Auto -->
<meta-data <meta-data
android:name="com.google.android.gms.car.application" android:name="com.google.android.gms.car.application"
@@ -0,0 +1,96 @@
package com.novasonic.app.data
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import com.novasonic.app.BuildConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
/**
* In-app updater for the sideloaded APK. Talks ONLY to the backend the app already knows:
* GET {backend}/api/apk/info → is there a newer versionCode than this build?
* GET {backend}/api/apk → download it (the backend proxies the fixed Gitea release)
* The user still confirms the system install (Android requires that for sideloaded APKs).
*
* No APK hoarding: the download goes to a single file under cacheDir/updates that is wiped before
* each download. An update (same package + signature) is an in-place install, so app data / offline
* downloads survive — provided the signing key stays stable across builds.
*/
object AppUpdater {
@Serializable
data class ApkInfo(
val available: Boolean = false,
val version: String? = null,
val versionCode: Int? = null,
val size: Long? = null,
)
private val json = Json { ignoreUnknownKeys = true }
private val _available = MutableStateFlow<ApkInfo?>(null)
val available: StateFlow<ApkInfo?> = _available
private val _downloading = MutableStateFlow(false)
val downloading: StateFlow<Boolean> = _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<ApkInfo>(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
}
}
}
@@ -80,6 +80,29 @@ fun AppNavigation() {
val appSettings by repo.appSettingsFlow().collectAsStateWithLifecycle(PrefsStore.AppSettings()) val appSettings by repo.appSettingsFlow().collectAsStateWithLifecycle(PrefsStore.AppSettings())
val lastPlayedSong by repo.lastPlayedSongFlow().collectAsStateWithLifecycle(null) 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 // 30s preview (app-level, so its control bar survives navigation). Starting real playback
// cancels any active preview so the two don't overlap. // cancels any active preview so the two don't overlap.
val previewState by app.previewController.state.collectAsStateWithLifecycle() val previewState by app.previewController.state.collectAsStateWithLifecycle()
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- The downloaded update APK lives in cacheDir/updates and is shared with the system installer. -->
<cache-path name="updates" path="updates/"/>
</paths>
+8 -3
View File
@@ -508,10 +508,14 @@ async function _giteaApkAsset() {
const rel = await _giteaApiJson(`/releases/tags/${encodeURIComponent(GITEA_APK_TAG)}`); const rel = await _giteaApiJson(`/releases/tags/${encodeURIComponent(GITEA_APK_TAG)}`);
const asset = (rel.assets || []).find(a => /\.apk$/i.test(a.name || '')); const asset = (rel.assets || []).find(a => /\.apk$/i.test(a.name || ''));
if (!asset) return null; 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 { return {
size: asset.size, size: asset.size,
url: asset.browser_download_url, url: asset.browser_download_url,
version: rel.name || rel.tag_name || null, 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(), 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) => { app.get('/api/apk/info', async (req, res) => {
try { try {
const a = await _giteaApkAsset(); 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 */ } } catch { /* fall through to local */ }
fs.stat(APK_PATH, (err, st) => { fs.stat(APK_PATH, (err, st) => {
if (err) return res.json({ available: false }); 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 {} 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' });
}); });
}); });
+22 -11
View File
@@ -17,7 +17,7 @@
// install over each other. Add a keystore + signingConfig later to switch APK_GRADLE_TASK=assembleRelease. // install over each other. Add a keystore + signingConfig later to switch APK_GRADLE_TASK=assembleRelease.
import { spawnSync } from 'node:child_process'; 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 { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path'; 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 API = `${GITEA_URL}/api/v1/repos/${GITEA_REPO}`;
const authHeaders = { Authorization: `token ${GITEA_TOKEN}` }; const authHeaders = { Authorization: `token ${GITEA_TOKEN}` };
// ── version name from the Gradle config (for the release title) ────────────── // ── bump versionCode + read versionName from the Gradle config ───────────────
let version = new Date().toISOString().slice(0, 10); // The in-app updater compares versionCode, so every published build MUST increment it — we do that
try { // here automatically (pass --version X.Y to also set the versionName). Commit the changed
const g = readFileSync(join(root, 'android', 'app', 'build.gradle.kts'), 'utf8'); // build.gradle.kts afterwards so the repo reflects what's live.
const m = g.match(/versionName\s*=\s*"([^"]+)"/); const gradlePath = join(root, 'android', 'app', 'build.gradle.kts');
if (m) version = m[1]; let gradle = readFileSync(gradlePath, 'utf8');
} catch {} 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 ───────────────────────────────────────────────────────────────── // ── 1) build ─────────────────────────────────────────────────────────────────
const isWin = process.platform === 'win32'; const isWin = process.platform === 'win32';
@@ -86,7 +97,7 @@ let rel;
const cr = await api('/releases', { const cr = await api('/releases', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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()}`); if (!cr.ok) die(`Release anlegen fehlgeschlagen: ${cr.status} ${await cr.text()}`);
rel = await cr.json(); 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 }); 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()}`); 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}`, { await api(`/releases/${rel.id}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: `NovaSonic ${version}` }), body: JSON.stringify({ name: `NovaSonic ${version}`, body: relBody }),
}).catch(() => {}); }).catch(() => {});
console.log(`\n✔ NovaSonic ${version} hochgeladen → Release "${TAG}". Der Web-Button serviert jetzt diese APK.`); console.log(`\n✔ NovaSonic ${version} hochgeladen → Release "${TAG}". Der Web-Button serviert jetzt diese APK.`);