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.`);