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:
1 parent
845f125d76
commit
2fd4d978d0
7 files changed
+175
-14
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
@@ -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.`);
|
||||||
Reference in new issue
Block a user