From 1bdd65cc76475fbf50125383a82a386f7b2579af Mon Sep 17 00:00:00 2001 From: "Timur Kh." Date: Thu, 22 Oct 2026 21:11:04 +0300 Subject: [PATCH] implement ota updates --- .env.example | 10 + .gitea/workflows/android-release.yml | 40 ++ README.md | 10 +- android/app/src/main/AndroidManifest.xml | 19 + .../kadence/android/KadenceApplication.kt | 11 + .../tech/kadence/android/MainActivity.kt | 3 + .../kadence/android/data/KadenceRepository.kt | 13 + .../kadence/android/data/dto/UpdateDtos.kt | 26 + .../kadence/android/data/net/KadenceApi.kt | 75 +++ .../android/data/store/KadencePrefs.kt | 17 + .../tech/kadence/android/ui/KadenceApp.kt | 27 ++ .../android/ui/settings/SettingsScreen.kt | 64 +++ .../kadence/android/ui/update/UpdateUi.kt | 298 ++++++++++++ .../kadence/android/update/ApkVerifier.kt | 87 ++++ .../kadence/android/update/UpdateManager.kt | 296 ++++++++++++ .../src/main/res/xml/update_file_paths.xml | 6 + .../tech/kadence/android/UpdateManagerTest.kt | 241 ++++++++++ backend/app/api/__init__.py | 2 + backend/app/api/routes_updates.py | 53 +++ backend/app/config.py | 24 + backend/app/services/android_updates.py | 373 +++++++++++++++ backend/tests/test_android_updates.py | 202 ++++++++ docker-compose.yml | 7 + docs/gitea-runner.md | 4 +- docs/production-builds.md | 449 ++++++++++++++++++ 25 files changed, 2353 insertions(+), 4 deletions(-) create mode 100644 android/app/src/main/kotlin/tech/kadence/android/data/dto/UpdateDtos.kt create mode 100644 android/app/src/main/kotlin/tech/kadence/android/ui/update/UpdateUi.kt create mode 100644 android/app/src/main/kotlin/tech/kadence/android/update/ApkVerifier.kt create mode 100644 android/app/src/main/kotlin/tech/kadence/android/update/UpdateManager.kt create mode 100644 android/app/src/main/res/xml/update_file_paths.xml create mode 100644 android/app/src/test/kotlin/tech/kadence/android/UpdateManagerTest.kt create mode 100644 backend/app/api/routes_updates.py create mode 100644 backend/app/services/android_updates.py create mode 100644 backend/tests/test_android_updates.py create mode 100644 docs/production-builds.md diff --git a/.env.example b/.env.example index 2469115..2083469 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,16 @@ SCHEDULER_ENABLED=true TELEGRAM_BOT_ENABLED=true LOG_LEVEL=INFO +# ---- Android OTA updates --------------------------------------------------- +# The repository is private, so the backend reads Actions artifacts on the +# phone's behalf. Create a read-only token at +# https://git.timka.su/user/settings/applications and paste it here; without it +# /api/updates/android answers 503 and the app simply never offers an update. +GITEA_URL=https://git.timka.su +GITEA_REPO=timka123/kadence +GITEA_TOKEN= +GITEA_UPDATE_BRANCH=master + # ---- web login (closes the browser UI to everyone but you) ----------------- # Unset: the browser UI is as open as the API ("off by default" above). Set # either of these and the login screen appears — the desktop app is untouched, diff --git a/.gitea/workflows/android-release.yml b/.gitea/workflows/android-release.yml index 4fbc08a..e28e9a4 100644 --- a/.gitea/workflows/android-release.yml +++ b/.gitea/workflows/android-release.yml @@ -74,6 +74,16 @@ jobs: printf '%s' "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/kadence.keystore" echo "signed=true" >> "$GITHUB_OUTPUT" + - name: Resolve the version name + run: | + if [[ "$GITHUB_REF" == refs/tags/v* ]]; then + VERSION="${GITHUB_REF#refs/tags/v}" + else + VERSION="0.1.0-master.${{ gitea.run_number }}" + fi + echo "KADENCE_VERSION_NAME=$VERSION" >> "$GITHUB_ENV" + echo "Resolved version $VERSION" + - name: Build the release APK working-directory: android env: @@ -103,12 +113,42 @@ jobs: fi fi + # A small, separately uploaded description of the APK. The backend's OTA + # endpoint reads this artifact on every check; keeping it out of the APK + # artifact avoids downloading tens of megabytes just to ask "is there a + # newer build?". + - name: Write the update metadata + run: | + APK=android/app/build/outputs/apk/release/app-release.apk + META_DIR=android/app/build/outputs/update + mkdir -p "$META_DIR" + + APKSIGNER="$(find "$ANDROID_HOME/build-tools" -name apksigner | sort | tail -1)" + SIGNER_SHA256="$("$APKSIGNER" verify --print-certs "$APK" \ + | sed -n 's/^Signer #1 certificate SHA-256 digest: //p' | head -1)" + + printf '{\n "version_code": %s,\n "version_name": "%s",\n "commit": "%s",\n "built_at": "%s",\n "size_bytes": %s,\n "sha256": "%s",\n "signer_sha256": "%s"\n}\n' \ + "${{ gitea.run_number }}" \ + "$KADENCE_VERSION_NAME" \ + "$GITHUB_SHA" \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + "$(stat -c%s "$APK")" \ + "$(sha256sum "$APK" | cut -d' ' -f1)" \ + "$SIGNER_SHA256" > "$META_DIR/update.json" + cat "$META_DIR/update.json" + - uses: actions/upload-artifact@v3 with: name: kadence-release-apk path: android/app/build/outputs/apk/release/app-release.apk if-no-files-found: error + - uses: actions/upload-artifact@v3 + with: + name: kadence-update-metadata + path: android/app/build/outputs/update/update.json + if-no-files-found: error + - name: Attach it to the release, when this is a tag if: startsWith(github.ref, 'refs/tags/v') env: diff --git a/README.md b/README.md index b38fcae..9579db9 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,7 @@ and you can fill everything in from **Settings**. | `SCHEDULER_ENABLED`, `TELEGRAM_BOT_ENABLED` | switch the background jobs / bot off | | `AUTH_USERNAME`, `AUTH_PASSWORD` | closes the **browser UI** behind a login; see [Going public](#going-public) | | `TELEGRAM_OWNER_ID`, `TELEGRAM_BOT_USERNAME` | lets that same browser login happen via the Telegram Login Widget | +| `GITEA_TOKEN`, `GITEA_REPO`, `GITEA_URL`, `GITEA_UPDATE_BRANCH` | lets the backend read the private repo's Actions artifacts for Android OTA updates; empty token disables the update endpoints | --- @@ -213,7 +214,7 @@ and you can fill everything in from **Settings**. * `backend/` — FastAPI app. `app/services/` holds the thinking (timer, tasks, stats, report, blocker policy, scheduler, bot); `app/sources/` holds one module - per integration; `app/api/` is a thin HTTP layer. 73 tests under `backend/tests`. + per integration; `app/api/` is a thin HTTP layer. 78 tests under `backend/tests`. * `frontend/` — React + TypeScript + MUI, built by Vite into the backend image and served from the same origin (so there is no CORS to configure). * **The look** — warm paper, ink type, hairline rules instead of boxes and @@ -270,14 +271,17 @@ Gitea Actions, in `.gitea/workflows/`: | Workflow | When | What | | --- | --- | --- | -| `test.yml` | push to master, every PR | backend (73), desktop (47), `:core` (193), and the web app's typecheck + build — four parallel jobs, none of which may need a running backend | +| `test.yml` | push to master, every PR | backend (78), desktop (47), `:core` (193), and the web app's typecheck + build — four parallel jobs, none of which may need a running backend | | `lint.yml` | push to master, every PR | `ruff check` over `backend/` and `desktop/`, with the rule set named in `backend/pyproject.toml` | | `docker.yml` | push to master, `v*` tags | builds the image, starts it against SQLite and waits for `/api/health`, then pushes `git.timka.su/timka123/kadence:latest` and `:` | -| `android-release.yml` | push to master, `v*` tags | `:app`'s suite (81, on the JVM), then the release APK — signed from secrets, with `apksigner verify` printing the certificate it actually used | +| `android-release.yml` | push to master, `v*` tags | `:app`'s suite (89, on the JVM), then the release APK and the small OTA metadata artifact — signed from secrets, with `apksigner verify` printing the certificate it actually used | **Nothing runs until a docker-mode runner is registered** — [`docs/gitea-runner.md`](docs/gitea-runner.md) is the guide, and `docker-compose.ci.yml` + `deploy/act-runner.yaml` are the two files it installs. +Once it is online, [`docs/production-builds.md`](docs/production-builds.md) covers +the release path: tagging, secrets, verifying the image and APK, deploying, and +rolling back. Short version, on the runner host: ```bash diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2b3a8ac..25b0a92 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -12,6 +12,12 @@ + + + @@ -81,6 +87,19 @@ android:supportsRtl="true" android:theme="@style/Theme.Kadence"> + + + + + = api.get("/api/auth/me") + // ---- updates ------------------------------------------------------------ + + /** The newest successful master build, as re-served by this backend. */ + suspend fun androidUpdate(): ApiResult = api.get("/api/updates/android") + + /** Download the APK behind [androidUpdate]. */ + suspend fun downloadAndroidUpdate( + target: File, + onProgress: (Long, Long?) -> Unit = { _, _ -> }, + ): ApiResult = api.download("/api/updates/android/apk", target, onProgress) + // ---- timer -------------------------------------------------------------- suspend fun timerState(): ApiResult = api.get("/api/timer/state") diff --git a/android/app/src/main/kotlin/tech/kadence/android/data/dto/UpdateDtos.kt b/android/app/src/main/kotlin/tech/kadence/android/data/dto/UpdateDtos.kt new file mode 100644 index 0000000..a32814f --- /dev/null +++ b/android/app/src/main/kotlin/tech/kadence/android/data/dto/UpdateDtos.kt @@ -0,0 +1,26 @@ +/** + * The Android OTA metadata the backend re-serves from Gitea Actions. + * + * Tolerant like every other DTO here: a backend that grows or drops a field must + * not turn an update check into a crash. The only field that decides anything is + * [AndroidUpdateDto.versionCode]; the rest is shown to the user or used as a + * pre-flight check before the APK is downloaded. + */ +package tech.kadence.android.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AndroidUpdateDto( + @SerialName("version_code") val versionCode: Long = 0, + @SerialName("version_name") val versionName: String = "", + val commit: String = "", + @SerialName("built_at") val builtAt: String? = null, + @SerialName("size_bytes") val sizeBytes: Long = 0, + val sha256: String = "", + @SerialName("signer_sha256") val signerSha256: String = "", + @SerialName("run_number") val runNumber: Long = 0, +) { + val shortCommit: String get() = commit.take(7) +} diff --git a/android/app/src/main/kotlin/tech/kadence/android/data/net/KadenceApi.kt b/android/app/src/main/kotlin/tech/kadence/android/data/net/KadenceApi.kt index 083b5a4..f1689b7 100644 --- a/android/app/src/main/kotlin/tech/kadence/android/data/net/KadenceApi.kt +++ b/android/app/src/main/kotlin/tech/kadence/android/data/net/KadenceApi.kt @@ -24,6 +24,7 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody +import java.io.File import java.io.IOException import java.util.concurrent.TimeUnit @@ -158,6 +159,80 @@ class KadenceApi( } } + /** + * Stream a binary response to [target], reporting progress as it goes. + * + * This exists for the OTA APK: `request()` returns the body as a String, and + * an APK is both binary and large. The write is staged in a `.part` file so a + * dropped connection can never leave a truncated APK looking installable. + */ + suspend fun download( + path: String, + target: File, + onProgress: (downloaded: Long, total: Long?) -> Unit = { _, _ -> }, + ): ApiResult = withContext(Dispatchers.IO) { + val base = config().baseUrl.trimEnd('/') + val url = "$base$path".toHttpUrlOrNull() + ?: return@withContext ApiResult.NetworkError( + IOException("not a usable backend URL: $base"), + ) + + val request = Request.Builder().url(url).build() + val partial = File(target.path + ".part") + try { + http.newCall(request).execute().use { response -> + if (response.code == 401 && !path.startsWith("/api/auth/")) { + unauthorized.publish() + return@withContext ApiResult.Unauthorized( + parseErrorDetail(response.body.string(), "not authorised"), + ) + } + if (!response.isSuccessful) { + return@withContext ApiResult.HttpError( + response.code, + parseErrorDetail( + response.body.string(), + "${response.code} ${response.message}", + ), + ) + } + + val body = response.body + target.parentFile?.mkdirs() + val total = body.contentLength().takeIf { it > 0 } + var downloaded = 0L + + try { + body.byteStream().use { input -> + partial.outputStream().use { output -> + val buffer = ByteArray(64 * 1024) + while (true) { + val read = input.read(buffer) + if (read < 0) break + output.write(buffer, 0, read) + downloaded += read + onProgress(downloaded, total) + } + } + } + if (!partial.renameTo(target)) { + partial.copyTo(target, overwrite = true) + partial.delete() + } + } catch (e: IOException) { + partial.delete() + target.delete() + throw e + } + ApiResult.Ok(Unit) + } + } catch (e: IOException) { + partial.delete() + target.delete() + ApiResult.NetworkError(e) + } + } + suspend inline fun get(path: String): ApiResult = decode(request("GET", path)) suspend inline fun post(path: String, body: String? = null): ApiResult = diff --git a/android/app/src/main/kotlin/tech/kadence/android/data/store/KadencePrefs.kt b/android/app/src/main/kotlin/tech/kadence/android/data/store/KadencePrefs.kt index f31d5e6..0e82aaf 100644 --- a/android/app/src/main/kotlin/tech/kadence/android/data/store/KadencePrefs.kt +++ b/android/app/src/main/kotlin/tech/kadence/android/data/store/KadencePrefs.kt @@ -60,6 +60,21 @@ class KadencePrefs(context: Context) { onboarded = prefs.getBoolean(KEY_ONBOARDED, false), ) + /** + * Update-check bookkeeping, deliberately outside [KadenceConfig]. + * + * These are not settings the user edits and they must not cause the whole + * config StateFlow — and with it every screen — to recompose. A build the + * user skipped stays skipped until they ask again from Settings. + */ + var skippedUpdateVersionCode: Long + get() = prefs.getLong(KEY_UPDATE_SKIPPED, 0L) + set(value) = prefs.edit().putLong(KEY_UPDATE_SKIPPED, value).apply() + + var lastUpdateCheckMillis: Long + get() = prefs.getLong(KEY_UPDATE_CHECKED_AT, 0L) + set(value) = prefs.edit().putLong(KEY_UPDATE_CHECKED_AT, value).apply() + fun update(transform: (KadenceConfig) -> KadenceConfig) { val next = transform(_config.value) prefs.edit().apply { @@ -88,6 +103,8 @@ class KadencePrefs(context: Context) { private const val KEY_DYNAMIC_ACCENT = "dynamic_accent" private const val KEY_ALARM = "alarm_on_complete" private const val KEY_ONBOARDED = "onboarded" + private const val KEY_UPDATE_SKIPPED = "update_skipped_version" + private const val KEY_UPDATE_CHECKED_AT = "update_checked_at" fun normalizeBaseUrl(raw: String): String { val trimmed = raw.trim().trimEnd('/') diff --git a/android/app/src/main/kotlin/tech/kadence/android/ui/KadenceApp.kt b/android/app/src/main/kotlin/tech/kadence/android/ui/KadenceApp.kt index 8d356f2..84bd1df 100644 --- a/android/app/src/main/kotlin/tech/kadence/android/ui/KadenceApp.kt +++ b/android/app/src/main/kotlin/tech/kadence/android/ui/KadenceApp.kt @@ -50,6 +50,9 @@ import tech.kadence.android.ui.settings.SettingsScreen import tech.kadence.android.ui.tasks.TasksScreen import tech.kadence.android.ui.theme.KadenceTheme import tech.kadence.android.ui.timer.TimerScreen +import tech.kadence.android.ui.update.UpdateBanner +import tech.kadence.android.ui.update.UpdateDialog +import tech.kadence.android.ui.update.hasUpdateSuggestion private enum class Destination(val label: String) { Timer("Timer"), @@ -63,8 +66,10 @@ private enum class Destination(val label: String) { fun KadenceApp(app: KadenceApplication) { val config by app.prefs.config.collectAsStateWithLifecycle() val ui by app.engine.state.collectAsStateWithLifecycle() + val updateState by app.updates.state.collectAsStateWithLifecycle() var showOnboarding by remember { mutableStateOf(!config.onboarded) } + var showUpdateDialog by remember { mutableStateOf(false) } var destination by remember { mutableStateOf(Destination.Timer) } val scope = rememberCoroutineScope() @@ -104,6 +109,7 @@ fun KadenceApp(app: KadenceApplication) { // it is, ask straight away rather than waiting out the interval. app.engine.refreshNow() app.blocker.refreshNow() + app.updates.check() }, ) } @@ -111,6 +117,16 @@ fun KadenceApp(app: KadenceApplication) { } Column(Modifier.fillMaxSize().statusBarsPadding()) { + if (hasUpdateSuggestion(updateState)) { + Box(Modifier.padding(horizontal = 20.dp, vertical = 8.dp)) { + UpdateBanner( + state = updateState, + onOpen = { showUpdateDialog = true }, + onLater = app.updates::later, + ) + } + } + Box(Modifier.weight(1f)) { when (destination) { Destination.Timer -> TimerScreen( @@ -177,6 +193,9 @@ fun KadenceApp(app: KadenceApplication) { } }, onReconnect = { showOnboarding = true }, + updateManager = app.updates, + updateState = updateState, + onOpenUpdate = { showUpdateDialog = true }, ) } } @@ -188,6 +207,14 @@ fun KadenceApp(app: KadenceApplication) { modifier = Modifier.navigationBarsPadding(), ) } + + if (showUpdateDialog) { + UpdateDialog( + manager = app.updates, + state = updateState, + onDismiss = { showUpdateDialog = false }, + ) + } } } diff --git a/android/app/src/main/kotlin/tech/kadence/android/ui/settings/SettingsScreen.kt b/android/app/src/main/kotlin/tech/kadence/android/ui/settings/SettingsScreen.kt index 64cd769..e6377be 100644 --- a/android/app/src/main/kotlin/tech/kadence/android/ui/settings/SettingsScreen.kt +++ b/android/app/src/main/kotlin/tech/kadence/android/ui/settings/SettingsScreen.kt @@ -46,6 +46,10 @@ import tech.kadence.android.ui.components.Panel import tech.kadence.android.ui.components.SectionTitle import tech.kadence.android.ui.components.ToggleRow import tech.kadence.android.ui.theme.KadenceTheme +import tech.kadence.android.ui.update.hasUpdateSuggestion +import tech.kadence.android.ui.update.updateHeadline +import tech.kadence.android.update.UpdateManager +import tech.kadence.android.update.UpdateState @Composable fun SettingsScreen( @@ -54,6 +58,9 @@ fun SettingsScreen( onUpdate: ((KadenceConfig) -> KadenceConfig) -> Unit, onSetTimerEnabled: (Boolean) -> Unit, onReconnect: () -> Unit, + updateManager: UpdateManager, + updateState: UpdateState, + onOpenUpdate: () -> Unit, ) { val colors = KadenceTheme.colors val context = LocalContext.current @@ -223,6 +230,48 @@ fun SettingsScreen( } } + SectionTitle("Updates") + Panel(padding = 0.dp) { + Column(Modifier.padding(14.dp)) { + Text( + text = "Kadence ${BuildConfig.VERSION_NAME} · build " + + "${BuildConfig.VERSION_CODE}", + style = KadenceTheme.type.body, + color = colors.ink, + ) + Text( + text = updateStatusText(updateManager, updateState), + style = KadenceTheme.type.caption, + color = when (updateState) { + is UpdateState.Failed -> colors.clay + is UpdateState.Ready, + is UpdateState.NeedsInstallPermission, + -> colors.pine + is UpdateState.UpToDate -> colors.pine + else -> colors.inkMuted + }, + ) + } + HorizontalRule() + Row( + Modifier.fillMaxWidth().padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + GhostButton( + text = "Check now", + onClick = { updateManager.check(force = true) }, + enabled = updateManager.updatesSupported && + updateState !is UpdateState.Checking && + updateState !is UpdateState.Downloading && + updateState !is UpdateState.Ready && + updateState !is UpdateState.NeedsInstallPermission, + ) + if (hasUpdateSuggestion(updateState)) { + GhostButton(text = "View update", onClick = onOpenUpdate) + } + } + } + SectionTitle("Appearance") Panel(padding = 0.dp) { ToggleRow( @@ -245,6 +294,21 @@ fun SettingsScreen( } } +private fun updateStatusText(manager: UpdateManager, state: UpdateState): String { + if (!manager.updatesSupported) return "OTA updates are disabled in debug builds." + return when (state) { + UpdateState.Idle -> "Checks run at most every six hours when you open the app." + UpdateState.Checking -> "Checking the backend for a newer build…" + UpdateState.UpToDate -> "This install is current." + is UpdateState.Available -> updateHeadline(state) + is UpdateState.Downloading -> "Downloading build ${state.update.versionCode}…" + is UpdateState.Ready -> "Downloaded and verified. Ready to install." + is UpdateState.NeedsInstallPermission -> + "Allow installs from Kadence, then tap Install." + is UpdateState.Failed -> state.message + } +} + /** Deep-link straight to Kadence's notification page, not the global one. */ private fun openAppNotificationSettings(context: Context) { val intent = Intent(AndroidSettings.ACTION_APP_NOTIFICATION_SETTINGS) diff --git a/android/app/src/main/kotlin/tech/kadence/android/ui/update/UpdateUi.kt b/android/app/src/main/kotlin/tech/kadence/android/ui/update/UpdateUi.kt new file mode 100644 index 0000000..b316955 --- /dev/null +++ b/android/app/src/main/kotlin/tech/kadence/android/ui/update/UpdateUi.kt @@ -0,0 +1,298 @@ +/** + * The two places OTA is visible: a quiet banner over the shell, and a dialog + * that carries the download/install states. + * + * The banner is deliberately not modal. An update is a suggestion, not an + * interruption — the timer screen must remain usable while the user decides. + */ +package tech.kadence.android.ui.update + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import java.util.Locale +import tech.kadence.android.data.dto.AndroidUpdateDto +import tech.kadence.android.ui.components.GhostButton +import tech.kadence.android.ui.components.HorizontalRule +import tech.kadence.android.ui.components.PrimaryButton +import tech.kadence.android.ui.theme.KadenceTheme +import tech.kadence.android.update.UpdateManager +import tech.kadence.android.update.UpdateState + +private fun updateOf(state: UpdateState): AndroidUpdateDto? = when (state) { + is UpdateState.Available -> state.update + is UpdateState.Downloading -> state.update + is UpdateState.Ready -> state.update + is UpdateState.NeedsInstallPermission -> state.update + is UpdateState.Failed -> state.update.takeUnless { state.quiet } + else -> null +} + +fun hasUpdateSuggestion(state: UpdateState): Boolean = updateOf(state) != null + +fun updateHeadline(state: UpdateState): String { + val update = updateOf(state) ?: return "" + return when (state) { + is UpdateState.Available -> "Build ${update.versionCode} is available." + is UpdateState.Downloading -> "Downloading build ${update.versionCode}…" + is UpdateState.Ready -> "Build ${update.versionCode} is ready to install." + is UpdateState.NeedsInstallPermission -> + "Allow installs from Kadence to finish the update." + is UpdateState.Failed -> state.message + else -> "" + } +} + +@Composable +fun UpdateBanner( + state: UpdateState, + onOpen: () -> Unit, + onLater: () -> Unit, +) { + val update = updateOf(state) ?: return + val colors = KadenceTheme.colors + val label = when (state) { + is UpdateState.Ready, is UpdateState.NeedsInstallPermission -> "Install" + is UpdateState.Failed -> "Retry" + else -> "Update" + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = colors.accentSoft, + border = androidx.compose.foundation.BorderStroke(1.dp, colors.accent.copy(alpha = 0.45f)), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column(Modifier.weight(1f)) { + Text( + text = "Update · ${displayName(update)}", + style = KadenceTheme.type.bodySmall, + color = colors.ink, + ) + Text( + text = when (state) { + is UpdateState.Downloading -> progressText(state) + is UpdateState.Ready -> "Verified. Android will ask you to confirm." + is UpdateState.NeedsInstallPermission -> + "Android needs permission to install unknown apps." + is UpdateState.Failed -> "The last attempt did not finish." + else -> "A newer build is ready from Gitea Actions." + }, + style = KadenceTheme.type.caption, + color = colors.inkMuted, + ) + } + if (state is UpdateState.Available) { + TextButton(onClick = onLater) { + Text("Later", style = KadenceTheme.type.button, color = colors.inkMuted) + } + } + TextButton(onClick = onOpen) { + Text(label, style = KadenceTheme.type.button, color = colors.accent) + } + } + } +} + +@Composable +fun UpdateDialog( + manager: UpdateManager, + state: UpdateState, + onDismiss: () -> Unit, +) { + val update = updateOf(state) + val colors = KadenceTheme.colors + + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = RoundedCornerShape(16.dp), + color = colors.paper, + border = androidx.compose.foundation.BorderStroke(1.dp, colors.ruleStrong), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = when (state) { + is UpdateState.Ready, + is UpdateState.NeedsInstallPermission, + -> "Install update" + is UpdateState.Failed -> "Update not finished" + else -> "Update available" + }, + style = KadenceTheme.type.pageTitle, + color = colors.ink, + ) + HorizontalRule() + + if (update != null) { + Text( + text = displayName(update), + style = KadenceTheme.type.body, + color = colors.ink, + ) + Text( + text = buildString { + if (update.shortCommit.isNotBlank()) { + append("Commit ${update.shortCommit}") + } + if (update.builtAt != null) { + if (isNotEmpty()) append(" · ") + append(update.builtAt.take(10)) + } + if (update.sizeBytes > 0) { + if (isNotEmpty()) append(" · ") + append(readableSize(update.sizeBytes)) + } + }, + style = KadenceTheme.type.caption, + color = colors.inkMuted, + ) + } + + Text( + text = bodyText(state), + style = KadenceTheme.type.bodySmall, + color = if (state is UpdateState.Failed) colors.clay else colors.inkMuted, + ) + + if (state is UpdateState.Downloading) { + DownloadProgress(state) + } + + Spacer(Modifier.height(2.dp)) + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + when (state) { + is UpdateState.Available -> { + PrimaryButton("Download", onClick = manager::download) + GhostButton("Skip this build", onClick = { + manager.skip() + onDismiss() + }) + } + is UpdateState.Downloading -> { + GhostButton("Continue in background", onClick = onDismiss) + } + is UpdateState.Ready -> { + PrimaryButton("Install", onClick = manager::install) + GhostButton("Later", onClick = onDismiss) + } + is UpdateState.NeedsInstallPermission -> { + PrimaryButton("Install", onClick = manager::install) + GhostButton( + "Permission settings", + onClick = manager::openInstallPermissionSettings, + ) + } + is UpdateState.Failed -> { + if (update != null) { + PrimaryButton("Retry", onClick = manager::download) + } else { + PrimaryButton( + "Check again", + onClick = { manager.check(force = true) }, + ) + } + GhostButton("Close", onClick = { + manager.dismissError() + onDismiss() + }) + } + else -> GhostButton("Close", onClick = onDismiss) + } + } + } + } + } +} + +@Composable +private fun DownloadProgress(state: UpdateState.Downloading) { + val colors = KadenceTheme.colors + val fraction = state.total?.takeIf { it > 0 } + ?.let { (state.downloaded.toFloat() / it.toFloat()).coerceIn(0f, 1f) } + + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + if (fraction != null) { + Box( + Modifier + .fillMaxWidth() + .height(4.dp) + .background(colors.rule, RoundedCornerShape(2.dp)), + ) { + Box( + Modifier + .fillMaxWidth(fraction) + .height(4.dp) + .background(colors.accent, RoundedCornerShape(2.dp)), + ) + } + } + Text( + text = progressText(state), + style = KadenceTheme.type.caption, + color = colors.inkFaint, + ) + } +} + +private fun progressText(state: UpdateState.Downloading): String { + val total = state.total + return if (total != null && total > 0) { + val percent = (state.downloaded * 100 / total).coerceIn(0, 100) + "${readableSize(state.downloaded)} of ${readableSize(total)} · $percent%" + } else { + readableSize(state.downloaded) + } +} + +private fun bodyText(state: UpdateState): String = when (state) { + is UpdateState.Available -> "This build comes from the newest successful master run. " + + "It is downloaded only when you ask, then Android confirms the install." + is UpdateState.Downloading -> "Keep the app open or leave it in the background. " + + "If the transfer fails, Retry starts again from the beginning." + is UpdateState.Ready -> "The APK is signed with this app's key. Android will install " + + "it over the current build after you confirm." + is UpdateState.NeedsInstallPermission -> "Android blocks installs from apps that have " + + "not been granted this permission. Open the settings page, allow Kadence, then " + + "come back and tap Install." + is UpdateState.Failed -> state.message + is UpdateState.Checking -> "Asking the backend which build is current…" + is UpdateState.UpToDate -> "This install is on the newest successful master build." + UpdateState.Idle -> "No update is being offered right now." +} + +private fun displayName(update: AndroidUpdateDto): String = + update.versionName.ifBlank { "Build ${update.versionCode}" } + +private fun readableSize(bytes: Long): String = when { + bytes >= 1024L * 1024L -> + String.format(Locale.US, "%.1f MB", bytes / (1024.0 * 1024.0)) + bytes >= 1024L -> String.format(Locale.US, "%.0f KB", bytes / 1024.0) + else -> "$bytes B" +} diff --git a/android/app/src/main/kotlin/tech/kadence/android/update/ApkVerifier.kt b/android/app/src/main/kotlin/tech/kadence/android/update/ApkVerifier.kt new file mode 100644 index 0000000..a0a4fc6 --- /dev/null +++ b/android/app/src/main/kotlin/tech/kadence/android/update/ApkVerifier.kt @@ -0,0 +1,87 @@ +/** + * What makes it safe to hand a downloaded file to Android's installer. + * + * The backend is the user's own, and the APK comes over the same authenticated + * connection as everything else, but neither of those says the file is *this + * app's* next build. The signing certificate does. A build signed with another + * key cannot upgrade an installed app anyway; checking first turns an opaque + * "App not installed" into an honest refusal. + */ +package tech.kadence.android.update + +import android.content.Context +import android.content.pm.PackageManager +import android.content.pm.PackageInfo +import android.content.pm.SigningInfo +import java.io.File +import java.security.MessageDigest +import tech.kadence.android.data.dto.AndroidUpdateDto + +object ApkVerifier { + + fun currentSignerDigests(context: Context): Set = runCatching { + val info = context.packageManager.getPackageInfo( + context.packageName, + PackageManager.GET_SIGNING_CERTIFICATES, + ) + digests(info) + }.getOrDefault(emptySet()) + + /** True when the server's advertised signer is one of ours, or was not provided. */ + fun matchesExpectedSigner(update: AndroidUpdateDto, current: Set): Boolean { + val expected = normalize(update.signerSha256) + if (expected.isBlank()) return true + return current.any { normalize(it) == expected } + } + + /** + * @return null when the APK is installable over this app; otherwise a message + * for the user, with the file already known to be unusable. + */ + @Suppress("DEPRECATION") + fun verify( + context: Context, + apk: File, + installedVersionCode: Long, + ): String? { + if (!apk.isFile || apk.length() == 0L) return "The downloaded APK is empty." + + val manager = context.packageManager + val archive = runCatching { + manager.getPackageArchiveInfo(apk.path, PackageManager.GET_SIGNING_CERTIFICATES) + }.getOrNull() ?: return "The download is not a valid APK." + + if (archive.packageName != context.packageName) { + return "The downloaded APK belongs to a different app." + } + if (archive.longVersionCode <= installedVersionCode) { + return "The downloaded APK is not newer than the installed build." + } + + val archiveSigners = digests(archive) + if (archiveSigners.isEmpty()) return "The downloaded APK is not signed." + + val currentSigners = currentSignerDigests(context) + if (currentSigners.isEmpty()) { + return "Could not read this app's signing certificate." + } + if (archiveSigners != currentSigners) { + return "The downloaded build is signed with a different key, so Android " + + "would not install it over this app." + } + return null + } + + private fun digests(info: PackageInfo): Set { + val signing: SigningInfo? = info.signingInfo + val signatures = signing?.apkContentsSigners.orEmpty() + return signatures.mapTo(mutableSetOf()) { signature -> + MessageDigest.getInstance("SHA-256") + .digest(signature.toByteArray()) + .joinToString("") { "%02x".format(it) } + } + } + + private fun normalize(digest: String): String = + digest.replace(":", "").trim().lowercase() +} diff --git a/android/app/src/main/kotlin/tech/kadence/android/update/UpdateManager.kt b/android/app/src/main/kotlin/tech/kadence/android/update/UpdateManager.kt new file mode 100644 index 0000000..73782e4 --- /dev/null +++ b/android/app/src/main/kotlin/tech/kadence/android/update/UpdateManager.kt @@ -0,0 +1,296 @@ +/** + * The OTA state machine. + * + * It checks a feed the backend builds from Gitea Actions, offers the build when + * it is genuinely newer, downloads only after the user says so, verifies the + * signing key, and then lets Android's own installer ask the final question. + * + * The manager never installs anything silently and never keeps a partial file: + * a failed download reverts to a retryable state, and an APK that fails the + * signature check is deleted before the user is told why. + */ +package tech.kadence.android.update + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.Settings as AndroidSettings +import androidx.core.content.FileProvider +import java.io.File +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import tech.kadence.android.BuildConfig +import tech.kadence.android.data.KadenceRepository +import tech.kadence.android.data.dto.AndroidUpdateDto +import tech.kadence.android.data.net.ApiResult +import tech.kadence.android.data.store.KadencePrefs + +sealed interface UpdateState { + data object Idle : UpdateState + data object Checking : UpdateState + data object UpToDate : UpdateState + + data class Available(val update: AndroidUpdateDto) : UpdateState + + data class Downloading( + val update: AndroidUpdateDto, + val downloaded: Long, + val total: Long?, + ) : UpdateState + + data class Ready(val update: AndroidUpdateDto, val apk: File) : UpdateState + + data class NeedsInstallPermission( + val update: AndroidUpdateDto, + val apk: File, + ) : UpdateState + + data class Failed( + val message: String, + /** Set when a download failed, so the banner can offer a retry. */ + val update: AndroidUpdateDto? = null, + /** True for background checks: Settings can show it, the banner stays quiet. */ + val quiet: Boolean = false, + ) : UpdateState +} + +class UpdateManager( + private val context: Context, + private val repo: KadenceRepository, + private val prefs: KadencePrefs, + private val scope: CoroutineScope, + private val installedVersionCode: Long = BuildConfig.VERSION_CODE.toLong(), + private val debugBuild: Boolean = BuildConfig.DEBUG, + private val now: () -> Long = System::currentTimeMillis, + private val checkIntervalMillis: Long = 6 * 60 * 60 * 1000L, +) { + private val _state = MutableStateFlow(UpdateState.Idle) + val state: StateFlow = _state.asStateFlow() + + private val apkDir = File(context.cacheDir, "updates") + private var work: Job? = null + + val updatesSupported: Boolean get() = !debugBuild + + /** + * Look for a newer build at most once per [checkIntervalMillis]. + * + * @param force bypass the interval and the "skipped this build" choice. This + * is the Settings button; automatic resume checks use the quiet path. + */ + fun check(force: Boolean = false) { + if (!updatesSupported) { + if (force) { + _state.value = UpdateState.Failed( + "OTA updates are only available in release builds.", + ) + } + return + } + if (work?.isActive == true) return + if (!force && recentlyChecked()) return + + val config = prefs.current + if (!config.isConfigured || !config.onboarded) return + + // A ready APK must not be replaced by a fresh check while the user is + // deciding whether to install it. + if (_state.value is UpdateState.Downloading || + _state.value is UpdateState.Ready || + _state.value is UpdateState.NeedsInstallPermission + ) { + return + } + + work = scope.launch { + _state.value = UpdateState.Checking + val result = repo.androidUpdate() + prefs.lastUpdateCheckMillis = now() + + when (result) { + is ApiResult.Ok -> offer(result.value, force) + is ApiResult.HttpError -> failedCheck(result.detail, force) + is ApiResult.Unauthorized -> failedCheck(result.detail, force) + is ApiResult.NetworkError -> failedCheck( + "Could not reach the backend to check for updates.", + force, + ) + } + } + } + + private fun offer(update: AndroidUpdateDto, force: Boolean) { + if (update.versionCode <= installedVersionCode) { + _state.value = UpdateState.UpToDate + return + } + + val currentSigners = ApkVerifier.currentSignerDigests(context) + if (!ApkVerifier.matchesExpectedSigner(update, currentSigners)) { + _state.value = UpdateState.Failed( + "The newest build is signed with a different key, so it cannot " + + "upgrade this install.", + quiet = !force, + ) + return + } + + if (!force && update.versionCode == prefs.skippedUpdateVersionCode) { + _state.value = UpdateState.Idle + return + } + + _state.value = UpdateState.Available(update) + } + + private fun failedCheck(message: String, force: Boolean) { + _state.value = UpdateState.Failed(message, quiet = !force) + } + + private fun recentlyChecked(): Boolean { + val last = prefs.lastUpdateCheckMillis + return last > 0 && now() - last in 0 until checkIntervalMillis + } + + /** Download the offered build, verify it, and stop at "ready to install". */ + fun download() { + if (work?.isActive == true) return + val update = when (val current = _state.value) { + is UpdateState.Available -> current.update + is UpdateState.Failed -> current.update + else -> null + } ?: return + + val target = File(apkDir, "kadence-${update.versionCode}.apk") + work = scope.launch { + cleanupOldApks(keep = target) + _state.value = UpdateState.Downloading( + update, + downloaded = 0, + total = update.sizeBytes.takeIf { it > 0 }, + ) + + val result = repo.downloadAndroidUpdate(target) { done, total -> + _state.value = UpdateState.Downloading( + update, + downloaded = done, + total = total ?: update.sizeBytes.takeIf { it > 0 }, + ) + } + + when (result) { + is ApiResult.Ok -> { + val problem = ApkVerifier.verify(context, target, installedVersionCode) + if (problem != null) { + target.delete() + _state.value = UpdateState.Failed(problem, update = update) + } else { + _state.value = UpdateState.Ready(update, target) + } + } + is ApiResult.HttpError -> downloadFailed(update, result.detail) + is ApiResult.Unauthorized -> downloadFailed(update, result.detail) + is ApiResult.NetworkError -> downloadFailed( + update, + "The download was interrupted. Try again.", + ) + } + } + } + + private fun downloadFailed(update: AndroidUpdateDto, message: String) { + _state.value = UpdateState.Failed(message, update = update) + } + + /** + * Hand the verified APK to Android's package installer. + * + * If the user has not allowed Kadence to install unknown apps, the state + * moves to [UpdateState.NeedsInstallPermission] and the UI offers the + * matching system settings page. It does not silently drop the download. + */ + fun install() { + val ready: UpdateState.Ready = when (val current = _state.value) { + is UpdateState.Ready -> current + is UpdateState.NeedsInstallPermission -> + UpdateState.Ready(current.update, current.apk) + else -> return + } + + if (!context.packageManager.canRequestPackageInstalls()) { + _state.value = UpdateState.NeedsInstallPermission(ready.update, ready.apk) + // Take the user straight to the grant. The same "Install" button then + // works when they come back, without needing a second tap on a + // different label. + openInstallPermissionSettings() + return + } + + val uri = runCatching { + FileProvider.getUriForFile( + context, + "${context.packageName}.updates", + ready.apk, + ) + }.getOrElse { + _state.value = UpdateState.Failed( + "Could not open the downloaded APK.", + update = ready.update, + ) + return + } + + val intent = Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, "application/vnd.android.package-archive") + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + runCatching { context.startActivity(intent) }.onFailure { + _state.value = UpdateState.Failed( + "No package installer is available on this device.", + update = ready.update, + ) + } + } + + fun openInstallPermissionSettings() { + val intent = Intent( + AndroidSettings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, + Uri.parse("package:${context.packageName}"), + ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + runCatching { context.startActivity(intent) } + } + + /** Hide the suggestion until the next launch, without skipping the build. */ + fun later() { + if (_state.value is UpdateState.Available) { + _state.value = UpdateState.Idle + } + } + + /** Never suggest this exact build again, until a manual check. */ + fun skip() { + val update = when (val current = _state.value) { + is UpdateState.Available -> current.update + is UpdateState.Failed -> current.update + else -> null + } ?: return + prefs.skippedUpdateVersionCode = update.versionCode + _state.value = UpdateState.Idle + } + + fun dismissError() { + if (_state.value is UpdateState.Failed) _state.value = UpdateState.Idle + } + + private fun cleanupOldApks(keep: File) { + apkDir.listFiles()?.forEach { file -> + if (file != keep && (file.name.endsWith(".apk") || file.name.endsWith(".part"))) { + file.delete() + } + } + } +} diff --git a/android/app/src/main/res/xml/update_file_paths.xml b/android/app/src/main/res/xml/update_file_paths.xml new file mode 100644 index 0000000..b78ec6a --- /dev/null +++ b/android/app/src/main/res/xml/update_file_paths.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/app/src/test/kotlin/tech/kadence/android/UpdateManagerTest.kt b/android/app/src/test/kotlin/tech/kadence/android/UpdateManagerTest.kt new file mode 100644 index 0000000..efa6a5a --- /dev/null +++ b/android/app/src/test/kotlin/tech/kadence/android/UpdateManagerTest.kt @@ -0,0 +1,241 @@ +/** + * The update offer, without downloading anything real. + * + * MockWebServer stands in for the backend, so these tests pin the decisions that are + * easy to get wrong: a build that is not newer is ignored, a skipped build stays + * skipped until asked for, the six-hour throttle really throttles, and a signing-key + * mismatch never becomes an "install" button. + */ +package tech.kadence.android + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import java.io.File +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import tech.kadence.android.data.KadenceRepository +import tech.kadence.android.data.net.AuthConfig +import tech.kadence.android.data.net.KadenceApi +import tech.kadence.android.data.store.KadencePrefs +import tech.kadence.android.update.UpdateManager +import tech.kadence.android.update.UpdateState +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class UpdateManagerTest { + + private lateinit var server: MockWebServer + private lateinit var context: Context + private lateinit var prefs: KadencePrefs + private var clock = 1_000_000L + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + + private fun fixtureJson( + versionCode: Long, + signer: String = "", + ): String = """ + { + "version_code": $versionCode, + "version_name": "0.1.0-master.$versionCode", + "commit": "abcdef1234567890", + "built_at": "2026-10-22T10:00:00Z", + "size_bytes": 1234567, + "sha256": "deadbeef", + "signer_sha256": "$signer", + "run_number": $versionCode + } + """.trimIndent() + + private fun jsonResponse(code: Int, body: String): MockResponse = + MockResponse.Builder() + .code(code) + .addHeader("Content-Type", "application/json") + .body(body) + .build() + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + context.getSharedPreferences("kadence", Context.MODE_PRIVATE) + .edit().clear().commit() + prefs = KadencePrefs(context) + prefs.update { + it.copy( + baseUrl = "http://placeholder", + apiToken = "token", + onboarded = true, + ) + } + + server = MockWebServer() + server.start() + prefs.update { it.copy(baseUrl = server.url("/").toString().trimEnd('/')) } + } + + @After + fun tearDown() { + server.close() + File(context.cacheDir, "updates").deleteRecursively() + } + + private fun manager( + installedVersionCode: Long = 4L, + debugBuild: Boolean = false, + ): UpdateManager { + val api = KadenceApi(config = { + object : AuthConfig { + override val baseUrl = prefs.current.baseUrl + override val apiToken = prefs.current.apiToken + } + }) + return UpdateManager( + context = context, + repo = KadenceRepository(api), + prefs = prefs, + scope = scope, + installedVersionCode = installedVersionCode, + debugBuild = debugBuild, + now = { clock }, + ) + } + + private fun await( + manager: UpdateManager, + predicate: (UpdateState) -> Boolean, + ): UpdateState = runBlocking { + withTimeout(5_000) { manager.state.first(predicate) } + } + + @Test + fun `a newer build is offered and remembered as checked`() { + server.enqueue(jsonResponse(200, fixtureJson(versionCode = 9))) + val manager = manager() + + manager.check() + val state = await(manager) { it !is UpdateState.Checking } + + val available = assertIs(state) + assertEquals(9L, available.update.versionCode) + assertEquals(1, server.requestCount) + assertEquals(clock, prefs.lastUpdateCheckMillis) + } + + @Test + fun `a build that is not newer does not become an offer`() { + server.enqueue(jsonResponse(200, fixtureJson(versionCode = 4))) + val manager = manager(installedVersionCode = 4L) + + manager.check() + val state = await(manager) { it !is UpdateState.Checking } + + assertIs(state) + } + + @Test + fun `resume checks inside six hours do not ask again`() { + server.enqueue(jsonResponse(200, fixtureJson(versionCode = 9))) + val manager = manager() + + manager.check() + await(manager) { it !is UpdateState.Checking } + clock += 60_000 + + manager.check() + + assertEquals(1, server.requestCount) + } + + @Test + fun `a skipped build stays skipped until the user asks again`() { + server.enqueue(jsonResponse(200, fixtureJson(versionCode = 9))) + server.enqueue(jsonResponse(200, fixtureJson(versionCode = 9))) + server.enqueue(jsonResponse(200, fixtureJson(versionCode = 9))) + val manager = manager() + + manager.check() + val offered = assertIs( + await(manager) { it !is UpdateState.Checking }, + ) + manager.skip() + assertIs(manager.state.value) + assertEquals(9L, prefs.skippedUpdateVersionCode) + + clock += 7 * 60 * 60 * 1000L + manager.check() + assertIs(await(manager) { it is UpdateState.Idle }) + assertEquals(2, server.requestCount) + + manager.check(force = true) + val forced = assertIs( + await(manager) { it is UpdateState.Available }, + ) + assertEquals(offered.update.versionCode, forced.update.versionCode) + assertEquals(3, server.requestCount) + } + + @Test + fun `a signing key mismatch is refused before any download`() { + val wrongKey = "00".repeat(32) + server.enqueue(jsonResponse(200, fixtureJson(versionCode = 9, signer = wrongKey))) + val manager = manager() + + manager.check() + val state = await(manager) { it !is UpdateState.Checking } + + val failed = assertIs(state) + assertTrue(failed.message.contains("signed with a different key")) + } + + @Test + fun `a failed download keeps the update around for retry`() { + server.enqueue(jsonResponse(200, fixtureJson(versionCode = 9))) + server.enqueue(MockResponse.Builder().code(500).body("nope").build()) + val manager = manager() + + manager.check() + await(manager) { it is UpdateState.Available } + manager.download() + val state = await(manager) { it is UpdateState.Failed } + + val failed = assertIs(state) + assertEquals(9L, failed.update?.versionCode) + assertTrue(!File(context.cacheDir, "updates/kadence-9.apk").exists()) + } + + @Test + fun `debug builds do not check at all`() { + val manager = manager(debugBuild = true) + + manager.check(force = true) + + val failed = assertIs(manager.state.value) + assertTrue(failed.message.contains("release builds")) + assertEquals(0, server.requestCount) + } + + @Test + fun `an unconfigured install stays quiet`() { + prefs.update { it.copy(onboarded = false) } + val manager = manager() + + manager.check() + + assertIs(manager.state.value) + assertEquals(0, server.requestCount) + } +} diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 0158143..adbf4d8 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -8,6 +8,7 @@ from app.api import ( routes_settings, routes_tasks, routes_timer, + routes_updates, ) api_router = APIRouter() @@ -16,5 +17,6 @@ api_router.include_router(routes_tasks.router) api_router.include_router(routes_blocker.router) api_router.include_router(routes_reports.router) api_router.include_router(routes_settings.router) +api_router.include_router(routes_updates.router) __all__ = ["api_router"] diff --git a/backend/app/api/routes_updates.py b/backend/app/api/routes_updates.py new file mode 100644 index 0000000..bc61205 --- /dev/null +++ b/backend/app/api/routes_updates.py @@ -0,0 +1,53 @@ +"""The Android OTA feed. + +The APK never comes from the phone's own Gitea credentials — the repository is +private. The backend reads the newest successful Actions artifact and re-serves +it here, behind the same auth as the rest of `/api`. +""" + +from __future__ import annotations + +from fastapi import APIRouter, HTTPException +from fastapi.responses import FileResponse + +from app.services import android_updates + +router = APIRouter(prefix="/api/updates", tags=["updates"]) + + +def _raise_http(exc: android_updates.UpdateUnavailable) -> None: + if isinstance(exc, android_updates.UpdateNotConfigured): + raise HTTPException(status_code=503, detail=str(exc)) from exc + if isinstance(exc, android_updates.UpdateNotFound): + raise HTTPException(status_code=404, detail=str(exc)) from exc + raise HTTPException(status_code=502, detail=str(exc)) from exc + + +@router.get("/android") +def android_update() -> dict: + """Metadata for the newest successful master build. + + The client compares `version_code` with its own; the backend deliberately + does not try to know which build the phone is running. + """ + try: + return android_updates.latest_android() + except android_updates.UpdateUnavailable as exc: + _raise_http(exc) + + +@router.get("/android/apk") +def android_apk() -> FileResponse: + """The APK for the newest successful master build.""" + try: + path, metadata = android_updates.android_apk() + except android_updates.UpdateUnavailable as exc: + _raise_http(exc) + + version = metadata.get("version_name") or metadata.get("version_code") or "latest" + return FileResponse( + path, + media_type="application/vnd.android.package-archive", + filename=f"kadence-{version}.apk", + headers={"Cache-Control": "no-store"}, + ) diff --git a/backend/app/config.py b/backend/app/config.py index d996747..b7d9853 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -55,6 +55,30 @@ class EnvSettings(BaseSettings): default=True, description="Run the Telegram command bot when a token is configured.", ) + + # ---- Android OTA updates ----------------------------------------------- + # The Gitea repository is private, so these cannot be read anonymously. + # The token is a read-only personal access token; it never leaves the + # backend, and the Android client only sees /api/updates/android. + gitea_url: str = Field( + default="https://git.timka.su", + description="Base URL of the Gitea instance that runs the Android workflow.", + ) + gitea_repo: str = Field( + default="timka123/kadence", + description="owner/repository whose Actions artifacts carry the APK.", + ) + gitea_token: str = Field( + default="", + description=( + "Read-only Gitea token used to read Actions artifacts. Empty disables " + "the Android update endpoint." + ), + ) + gitea_update_branch: str = Field( + default="master", + description="Only successful builds from this branch are offered as updates.", + ) api_token: str = Field( default="", description=( diff --git a/backend/app/services/android_updates.py b/backend/app/services/android_updates.py new file mode 100644 index 0000000..7904a30 --- /dev/null +++ b/backend/app/services/android_updates.py @@ -0,0 +1,373 @@ +"""The Android OTA feed, read from this repository's Gitea Actions artifacts. + +The Gitea repository is private, so the phone cannot read Actions artifacts +itself. This module is the only place that touches the Gitea token: it finds the +newest successful build from the configured branch, reads the small metadata +artifact, and pulls the APK out of the matching artifact zip on demand. + +Nothing here trusts the metadata blindly. Every field falls back to what the +Gitea run itself reports, so an older artifact that predates `update.json` still +produces a usable answer instead of a 500. +""" + +from __future__ import annotations + +import json +import logging +import shutil +import tempfile +import threading +import time +import zipfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import httpx + +from app.config import EnvSettings, env_settings + +log = logging.getLogger(__name__) + +METADATA_ARTIFACT = "kadence-update-metadata" +APK_ARTIFACT = "kadence-release-apk" + +_METADATA_TTL_SECONDS = 60.0 +_HTTP_TIMEOUT = 30.0 +_DOWNLOAD_TIMEOUT = 120.0 +_MAX_METADATA_BYTES = 1_000_000 +_CACHE_DIR = Path(tempfile.gettempdir()) / "kadence-updates" + + +class UpdateUnavailable(RuntimeError): + """The feed cannot be built right now.""" + + +class UpdateNotConfigured(UpdateUnavailable): + """No Gitea token was supplied, so there is nothing to read.""" + + +class UpdateNotFound(UpdateUnavailable): + """There is no successful artifact for the configured branch.""" + + +@dataclass(frozen=True, slots=True) +class ResolvedUpdate: + metadata: dict[str, Any] + apk_artifact_id: int + + +@dataclass(frozen=True, slots=True) +class Artifact: + id: int + name: str + created_at: str + size_bytes: int + expired: bool + run_id: int + run_number: int + branch: str + commit: str + conclusion: str + + +_lock = threading.Lock() +_resolved_cache: tuple[float, ResolvedUpdate] | None = None +_apk_cache: dict[int, Path] = {} + + +def reset_cache() -> None: + """Drop the in-process caches. Tests use this between Gitea responses.""" + global _resolved_cache + with _lock: + _resolved_cache = None + _apk_cache.clear() + + +# ---- Gitea reads ----------------------------------------------------------- + + +def _settings() -> EnvSettings: + settings = env_settings() + if not settings.gitea_token: + raise UpdateNotConfigured( + "Android updates are not configured: set GITEA_TOKEN on the backend." + ) + if not settings.gitea_repo or "/" not in settings.gitea_repo: + raise UpdateNotConfigured("Android updates need GITEA_REPO as owner/repository.") + return settings + + +def _client(settings: EnvSettings, *, timeout: float = _HTTP_TIMEOUT) -> httpx.Client: + return httpx.Client( + base_url=settings.gitea_url.rstrip("/"), + headers={ + "Authorization": f"token {settings.gitea_token}", + "Accept": "application/json", + }, + timeout=timeout, + follow_redirects=True, + ) + + +def _api(settings: EnvSettings, path: str) -> str: + return f"/api/v1/repos/{settings.gitea_repo}{path}" + + +def _artifact(raw: Any) -> Artifact | None: + if not isinstance(raw, dict): + return None + run = raw.get("workflow_run") + run = run if isinstance(run, dict) else {} + try: + artifact_id = int(raw.get("id") or 0) + except (TypeError, ValueError): + return None + if artifact_id <= 0: + return None + return Artifact( + id=artifact_id, + name=str(raw.get("name") or ""), + created_at=str(raw.get("created_at") or ""), + size_bytes=int(raw.get("size_in_bytes") or 0), + expired=bool(raw.get("expired")), + run_id=int(run.get("id") or 0), + run_number=int(run.get("run_number") or 0), + branch=str(run.get("head_branch") or ""), + commit=str(run.get("head_sha") or ""), + conclusion=str(run.get("conclusion") or ""), + ) + + +def _list_artifacts( + client: httpx.Client, settings: EnvSettings, name: str +) -> list[Artifact]: + try: + response = client.get(_api(settings, "/actions/artifacts"), params={"name": name}) + response.raise_for_status() + payload = response.json() + except (httpx.HTTPError, ValueError) as exc: + raise UpdateUnavailable(f"Could not read Gitea artifacts: {exc}") from exc + + entries = payload.get("artifacts") if isinstance(payload, dict) else None + if not isinstance(entries, list): + return [] + return [artifact for raw in entries if (artifact := _artifact(raw)) is not None] + + +def _successful(artifacts: list[Artifact], branch: str) -> list[Artifact]: + """Newest first, and only successful builds from the requested branch.""" + usable = [ + artifact + for artifact in artifacts + if not artifact.expired + and artifact.conclusion == "success" + and artifact.branch == branch + ] + # `run_number` is the monotonic number Android uses as `versionCode`. The + # created_at string is the tie-breaker for older Gitea runs that report 0. + return sorted(usable, key=lambda item: (item.run_number, item.created_at), reverse=True) + + +def _download_artifact(client: httpx.Client, settings: EnvSettings, artifact: Artifact) -> Path: + """Download one artifact zip to a temporary file and return its path.""" + _CACHE_DIR.mkdir(parents=True, exist_ok=True) + target = _CACHE_DIR / f"artifact-{artifact.id}.zip" + if target.is_file() and target.stat().st_size > 0: + return target + + temporary = target.with_suffix(".zip.part") + try: + with client.stream( + "GET", + _api(settings, f"/actions/artifacts/{artifact.id}/zip"), + timeout=_DOWNLOAD_TIMEOUT, + ) as response: + response.raise_for_status() + with temporary.open("wb") as handle: + for chunk in response.iter_bytes(): + handle.write(chunk) + if temporary.stat().st_size == 0: + raise UpdateUnavailable("Gitea returned an empty artifact archive.") + temporary.replace(target) + except (httpx.HTTPError, OSError) as exc: + temporary.unlink(missing_ok=True) + raise UpdateUnavailable(f"Could not download the Gitea artifact: {exc}") from exc + return target + + +def _member(zf: zipfile.ZipFile, basename: str) -> zipfile.ZipInfo | None: + for info in zf.infolist(): + if not info.is_dir() and Path(info.filename).name == basename: + return info + return None + + +def _read_metadata(client: httpx.Client, settings: EnvSettings, artifact: Artifact) -> dict[str, Any]: + archive = _download_artifact(client, settings, artifact) + try: + with zipfile.ZipFile(archive) as zf: + info = _member(zf, "update.json") + if info is None: + raise UpdateUnavailable("The update metadata artifact has no update.json.") + if info.file_size > _MAX_METADATA_BYTES: + raise UpdateUnavailable("The update metadata artifact is unexpectedly large.") + raw = json.loads(zf.read(info).decode("utf-8")) + except (zipfile.BadZipFile, OSError, UnicodeDecodeError, ValueError) as exc: + raise UpdateUnavailable(f"Could not read the update metadata: {exc}") from exc + return raw if isinstance(raw, dict) else {} + + +def _int(value: Any, fallback: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return fallback + + +def _metadata_from_artifact(raw: dict[str, Any], artifact: Artifact) -> dict[str, Any]: + run_number = artifact.run_number + return { + "version_code": _int(raw.get("version_code"), run_number), + "version_name": str( + raw.get("version_name") or f"0.1.0-master.{run_number or artifact.run_id}" + ), + "commit": str(raw.get("commit") or artifact.commit), + "built_at": str(raw.get("built_at") or artifact.created_at), + "size_bytes": _int(raw.get("size_bytes"), artifact.size_bytes), + "sha256": str(raw.get("sha256") or ""), + "signer_sha256": str(raw.get("signer_sha256") or ""), + "run_number": run_number, + } + + +def _derive_metadata(artifact: Artifact) -> dict[str, Any]: + """For artifacts built before the metadata artifact existed.""" + return _metadata_from_artifact({}, artifact) + + +def _apk_for_run(artifacts: list[Artifact], update: Artifact) -> Artifact | None: + for artifact in artifacts: + if artifact.run_id and artifact.run_id == update.run_id and not artifact.expired: + return artifact + # Defensive fallback for an instance that omits workflow_run.id on an + # artifact but keeps the run number. + if update.run_number: + for artifact in artifacts: + if artifact.run_number == update.run_number and not artifact.expired: + return artifact + return None + + +def _resolve(settings: EnvSettings) -> ResolvedUpdate: + global _resolved_cache + + now = time.monotonic() + with _lock: + cached = _resolved_cache + if cached is not None and now - cached[0] < _METADATA_TTL_SECONDS: + return cached[1] + + with _client(settings) as client: + metadata_artifacts = _successful( + _list_artifacts(client, settings, METADATA_ARTIFACT), + settings.gitea_update_branch, + ) + apk_artifacts = _list_artifacts(client, settings, APK_ARTIFACT) + usable_apk = _successful(apk_artifacts, settings.gitea_update_branch) + + resolved: ResolvedUpdate | None = None + for artifact in metadata_artifacts: + apk = _apk_for_run(usable_apk, artifact) + if apk is None: + continue + resolved = ResolvedUpdate( + metadata=_metadata_from_artifact( + _read_metadata(client, settings, artifact), artifact + ), + apk_artifact_id=apk.id, + ) + break + + # Before the first build after this feature landed there is no metadata + # artifact. The run number is the versionCode, so the newest APK + # artifact is still enough to offer an update. + if resolved is None and usable_apk: + newest = usable_apk[0] + resolved = ResolvedUpdate( + metadata=_derive_metadata(newest), + apk_artifact_id=newest.id, + ) + + if resolved is None: + raise UpdateNotFound( + f"No successful {settings.gitea_update_branch} build with an APK was found." + ) + + with _lock: + _resolved_cache = (time.monotonic(), resolved) + return resolved + + +def _apk_file(client: httpx.Client, settings: EnvSettings, artifact_id: int) -> Path: + with _lock: + cached = _apk_cache.get(artifact_id) + if cached is not None and cached.is_file() and cached.stat().st_size > 0: + return cached + + # The artifact list does not carry the APK's own record here, so fetch it by + # id. The id is enough for both the zip URL and the cache key. + artifact = Artifact( + id=artifact_id, + name=APK_ARTIFACT, + created_at="", + size_bytes=0, + expired=False, + run_id=0, + run_number=0, + branch="", + commit="", + conclusion="success", + ) + archive = _download_artifact(client, settings, artifact) + _CACHE_DIR.mkdir(parents=True, exist_ok=True) + target = _CACHE_DIR / f"kadence-{artifact_id}.apk" + temporary = target.with_suffix(".apk.part") + try: + with zipfile.ZipFile(archive) as zf: + info = _member(zf, "app-release.apk") + if info is None: + raise UpdateUnavailable("The APK artifact has no app-release.apk.") + with zf.open(info) as source, temporary.open("wb") as destination: + shutil.copyfileobj(source, destination) + if temporary.stat().st_size == 0: + raise UpdateUnavailable("The APK inside the artifact is empty.") + temporary.replace(target) + except (zipfile.BadZipFile, OSError) as exc: + temporary.unlink(missing_ok=True) + raise UpdateUnavailable(f"Could not extract the APK: {exc}") from exc + except UpdateUnavailable: + temporary.unlink(missing_ok=True) + raise + + with _lock: + _apk_cache[artifact_id] = target + return target + + +# ---- the public feed ------------------------------------------------------- + + +def latest_android() -> dict[str, Any]: + """The newest successful build, as JSON-ready metadata.""" + settings = _settings() + return _resolve(settings).metadata + + +def android_apk() -> tuple[Path, dict[str, Any]]: + """The APK for the newest build, along with its metadata.""" + settings = _settings() + resolved = _resolve(settings) + with _client(settings) as client: + path = _apk_file(client, settings, resolved.apk_artifact_id) + return path, resolved.metadata diff --git a/backend/tests/test_android_updates.py b/backend/tests/test_android_updates.py new file mode 100644 index 0000000..20b22b0 --- /dev/null +++ b/backend/tests/test_android_updates.py @@ -0,0 +1,202 @@ +"""The OTA feed: which artifact wins, and what the phone receives.""" + +from __future__ import annotations + +import io +import json +import zipfile + +import httpx +import pytest +from app.config import env_settings +from app.services import android_updates + + +def _configure(monkeypatch, *, token: str = "read-token") -> None: + monkeypatch.setenv("GITEA_URL", "https://git.example.test") + monkeypatch.setenv("GITEA_REPO", "timka123/kadence") + monkeypatch.setenv("GITEA_UPDATE_BRANCH", "master") + monkeypatch.setenv("GITEA_TOKEN", token) + env_settings.cache_clear() + android_updates.reset_cache() + + +def _artifact( + artifact_id: int, + *, + run_id: int, + run_number: int, + name: str = android_updates.METADATA_ARTIFACT, + branch: str = "master", + conclusion: str = "success", + expired: bool = False, + created_at: str = "2026-10-20T10:00:00Z", + size: int = 100, +) -> dict: + return { + "id": artifact_id, + "name": name, + "created_at": created_at, + "size_in_bytes": size, + "expired": expired, + "workflow_run": { + "id": run_id, + "run_number": run_number, + "head_branch": branch, + "head_sha": f"sha-{run_number}", + "conclusion": conclusion, + }, + } + + +def _zip(files: dict[str, bytes]) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + for name, body in files.items(): + archive.writestr(name, body) + return buffer.getvalue() + + +def _install_transport(monkeypatch, handler) -> None: + transport = httpx.MockTransport(handler) + + def client(_settings, *, timeout: float = 30.0) -> httpx.Client: + return httpx.Client( + base_url="https://git.example.test", + transport=transport, + timeout=timeout, + follow_redirects=True, + ) + + monkeypatch.setattr(android_updates, "_client", client) + + +def _metadata_body(version_code: int, version_name: str) -> bytes: + return json.dumps( + { + "version_code": version_code, + "version_name": version_name, + "commit": f"sha-{version_code}", + "built_at": "2026-10-20T10:00:00Z", + "size_bytes": 1234, + "sha256": "abc", + "signer_sha256": "def", + } + ).encode() + + +def test_newest_successful_master_build_wins(monkeypatch): + _configure(monkeypatch) + metadata_artifacts = [ + _artifact(11, run_id=101, run_number=10), + _artifact(12, run_id=102, run_number=20), + # A newer tag build must not become the master channel. + _artifact(13, run_id=103, run_number=30, branch="v0.2.0"), + # A failed build uploaded an artifact but must never be offered. + _artifact(14, run_id=104, run_number=40, conclusion="failure"), + # An expired artifact is not downloadable any more. + _artifact(15, run_id=105, run_number=50, expired=True), + ] + apk_artifacts = [ + _artifact( + artifact_id + 100, + run_id=run_id, + run_number=run_number, + name=android_updates.APK_ARTIFACT, + ) + for artifact_id, run_id, run_number in ( + (11, 101, 10), + (12, 102, 20), + (13, 103, 30), + (14, 104, 40), + (15, 105, 50), + ) + ] + zips = { + 12: _zip({"update.json": _metadata_body(20, "0.1.0-master.20")}), + 112: _zip({"app-release.apk": b"new apk"}), + } + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/actions/artifacts"): + name = request.url.params.get("name") + return httpx.Response( + 200, + json={ + "total_count": 5, + "artifacts": ( + metadata_artifacts + if name == android_updates.METADATA_ARTIFACT + else apk_artifacts + ), + }, + ) + artifact_id = int(request.url.path.rstrip("/").split("/")[-2]) + return httpx.Response(200, content=zips[artifact_id]) + + _install_transport(monkeypatch, handler) + + metadata = android_updates.latest_android() + assert metadata["version_code"] == 20 + assert metadata["version_name"] == "0.1.0-master.20" + assert metadata["commit"] == "sha-20" + assert metadata["size_bytes"] == 1234 + assert metadata["signer_sha256"] == "def" + + path, same = android_updates.android_apk() + assert path.read_bytes() == b"new apk" + assert same == metadata + + +def test_falls_back_to_an_apk_artifact_without_metadata(monkeypatch): + _configure(monkeypatch) + apk = _artifact( + 201, + run_id=301, + run_number=77, + name=android_updates.APK_ARTIFACT, + created_at="2026-10-21T09:30:00Z", + size=900, + ) + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/actions/artifacts"): + name = request.url.params.get("name") + return httpx.Response( + 200, + json={"total_count": 0 if name == android_updates.METADATA_ARTIFACT else 1, + "artifacts": [] if name == android_updates.METADATA_ARTIFACT else [apk]}, + ) + raise AssertionError("the metadata endpoint should not download anything") + + _install_transport(monkeypatch, handler) + + metadata = android_updates.latest_android() + assert metadata["version_code"] == 77 + assert metadata["version_name"] == "0.1.0-master.77" + assert metadata["commit"] == "sha-77" + assert metadata["built_at"] == "2026-10-21T09:30:00Z" + assert metadata["size_bytes"] == 900 + + +def test_missing_token_is_not_configured(monkeypatch): + _configure(monkeypatch, token="") + with pytest.raises(android_updates.UpdateNotConfigured): + android_updates.latest_android() + + +def test_gitea_failure_is_reported_as_unavailable(monkeypatch): + _configure(monkeypatch) + _install_transport(monkeypatch, lambda _request: httpx.Response(500, text="boom")) + + with pytest.raises(android_updates.UpdateUnavailable): + android_updates.latest_android() + + +def test_the_route_reports_503_when_updates_are_not_configured(client, monkeypatch): + _configure(monkeypatch, token="") + + response = client.get("/api/updates/android") + + assert response.status_code == 503 + assert "GITEA_TOKEN" in response.json()["detail"] diff --git a/docker-compose.yml b/docker-compose.yml index 54bb566..aa6fdfc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -46,6 +46,13 @@ services: TELEGRAM_BOT_ENABLED: ${TELEGRAM_BOT_ENABLED:-true} LOG_LEVEL: ${LOG_LEVEL:-INFO} + # Android OTA: the token never reaches the phone. Empty disables the + # update endpoints (they answer 503) without affecting anything else. + GITEA_URL: ${GITEA_URL:-https://git.timka.su} + GITEA_REPO: ${GITEA_REPO:-timka123/kadence} + GITEA_TOKEN: ${GITEA_TOKEN:-} + GITEA_UPDATE_BRANCH: ${GITEA_UPDATE_BRANCH:-master} + # Web login — see .env.example for what each does. AUTH_USERNAME: ${AUTH_USERNAME:-timka} AUTH_PASSWORD: ${AUTH_PASSWORD:-} diff --git a/docs/gitea-runner.md b/docs/gitea-runner.md index a8d25bc..2208c64 100644 --- a/docs/gitea-runner.md +++ b/docs/gitea-runner.md @@ -92,7 +92,9 @@ docker compose logs -f # expect: "runner registered successfully" ``` That is it. The runner appears in Gitea under the scope you took the token from, -with its labels listed beside it. +with its labels listed beside it. For the release process that uses this runner — +tags, secrets, artifacts, deployment and rollback — see +[`production-builds.md`](production-builds.md). ### What the pieces do diff --git a/docs/production-builds.md b/docs/production-builds.md new file mode 100644 index 0000000..b815a7f --- /dev/null +++ b/docs/production-builds.md @@ -0,0 +1,449 @@ +# Production builds in Gitea Actions + +This is the practical guide to turning a commit into something that can be +deployed or installed: the backend + web image, the signed Android APK, and what +to do about the desktop agent. It assumes the runner from +[`gitea-runner.md`](gitea-runner.md) is already registered and online. + +The short version: + +* a **push to `master`** produces a snapshot — `latest` plus the short commit SHA; +* a **`v*` tag** produces a release — the same image tagged with the version, and + an APK attached to the Gitea release. + +A tag is the only thing that means "production". `latest` moves with every push +and should never be pinned in a production compose file. + +* [What the workflows produce](#what-the-workflows-produce) +* [One-time setup](#one-time-setup) +* [Cutting a release](#cutting-a-release) +* [The backend + web image](#the-backend--web-image) +* [The Android APK](#the-android-apk) +* [Over-the-air updates](#over-the-air-updates) +* [The desktop agent](#the-desktop-agent) +* [Versioning and rollback](#versioning-and-rollback) +* [Downloading artifacts without the web UI](#downloading-artifacts-without-the-web-ui) +* [Troubleshooting](#troubleshooting) + +--- + +## What the workflows produce + +| Workflow | Trigger | Artifact | +| --- | --- | --- | +| `test.yml` | push to `master`, every pull request | No release artifact. It is the gate: backend, desktop, `:core`, and the web typecheck + build | +| `lint.yml` | push to `master`, every pull request | No release artifact. `ruff check` over `backend/` and `desktop/` | +| `docker.yml` | push to `master`, `v*` tags | Image `git.timka.su/timka123/kadence:` and `:latest`; on a tag also `:` | +| `android-release.yml` | push to `master`, `v*` tags | APK artifact `kadence-release-apk` and metadata `kadence-update-metadata`; on a tag also the release asset `kadence-.apk` | + +There is no build job for the desktop agent. See +[The desktop agent](#the-desktop-agent) for what that means in practice. + +## One-time setup + +### The runner + +Use the docker-mode runner from [`gitea-runner.md`](gitea-runner.md). The +Android job needs a real Android SDK and Gradle cache, and the Docker job needs +access to the host's Docker daemon through the mounted socket. A host-mode runner +answering `ubuntu-latest` is the most common reason a production build behaves +differently from the one in the docs. + +### Secrets + +All secrets live under **Repository → Settings → Actions → Secrets**. + +| Secret | Used by | What it must be | +| --- | --- | --- | +| `REGISTRY_TOKEN` | `docker.yml` | A personal access token with `package:write`. Gitea's built-in job token cannot publish packages | +| `REGISTRY_USER` | `docker.yml` | Optional. The registry username paired with the token; defaults to the repository owner | +| `KADENCE_KEYSTORE_BASE64` | `android-release.yml` | `base64 -w0 your-release.keystore` | +| `KADENCE_KEYSTORE_PASSWORD` | `android-release.yml` | The store password | +| `KADENCE_KEY_ALIAS` | `android-release.yml` | The key alias, normally `kadence` | +| `KADENCE_KEY_PASSWORD` | `android-release.yml` | The key password | + +The workflow uses Gitea's built-in `GITEA_TOKEN` to attach the APK to a tagged +release. That token is supplied by Actions; it is not one of the secrets above. + +Without `REGISTRY_TOKEN` the Docker workflow still builds and smoke-tests the +image, then skips the push with a warning. That is useful for a test repository, +not for production. + +Without the keystore secrets the Android job still builds an APK, but it is +**debug-signed**. It installs on your own phone and cannot be upgraded by a +properly signed release later. For production, set all four keystore values: +`KADENCE_REQUIRE_SIGNING` then makes a missing or misnamed secret fail the build +instead of silently falling back to the debug key. + +### The keystore + +If you do not already have one, create it once and keep it somewhere safe — in a +password manager or another encrypted backup, never in the repository: + +```bash +keytool -genkeypair -v \ + -keystore kadence-release.keystore \ + -alias kadence \ + -keyalg RSA -keysize 4096 -validity 10000 \ + -dname "CN=Kadence, O=Kadence" + +base64 -w0 kadence-release.keystore > kadence-release.keystore.b64 +``` + +Put the contents of `kadence-release.keystore.b64` into +`KADENCE_KEYSTORE_BASE64`. Losing this file means future APKs cannot upgrade the +installed app; Android treats them as a different application. + +### The tag convention + +Use annotated `v` tags with a semantic version: + +```bash +git tag -a v0.2.0 -m "Kadence 0.2.0" +git push origin v0.2.0 +``` + +`v0.2.0`, not `0.2.0`. Both publishing workflows filter on `v*`, and the Docker +workflow uses the tag as the image tag verbatim. + +## Cutting a release + +1. **Start from a green `master`.** The release workflows do not wait for the + test workflow, so check that the latest `master` run is green before tagging. + + ```bash + git checkout master + git pull --ff-only + git status --short + ``` + +2. **Tag the commit you mean to ship.** The tag is what the workflows check out; + an unpushed commit is not part of the build. + + ```bash + git tag -a v0.2.0 -m "Kadence 0.2.0" + git push origin v0.2.0 + ``` + +3. **Watch both workflows.** In Gitea: **Actions → `docker`** and + **Actions → `android`**. The Android job is the slower one on a cold cache + because it installs the SDK and fetches the Glyph Matrix AAR. + +4. **Verify the image.** It should exist under both the version and the commit: + + ```bash + docker pull git.timka.su/timka123/kadence:v0.2.0 + docker image inspect git.timka.su/timka123/kadence:v0.2.0 \ + --format '{{index .RepoDigests 0}}' + ``` + +5. **Verify the APK.** Download it from the release page + (`https://git.timka.su/timka123/kadence/releases/tag/v0.2.0`) and check the + certificate before putting it on a phone: + + ```bash + apksigner verify --print-certs kadence-v0.2.0.apk + ``` + + The certificate must be the release key, not `CN=Android Debug`. + +6. **Deploy or install.** The next two sections cover each artifact. + +## The backend + web image + +### What the job does + +`docker.yml` builds the single image from the repository `Dockerfile`: Vite +builds the web app, then the Python 3.12 runtime serves both the API and the +built static files. The job then starts the image against SQLite with the +scheduler and Telegram bot disabled and waits for `/api/health` before it pushes +anything. A tag that cannot boot is not published. + +The pushed names are: + +| Tag | Meaning | +| --- | --- | +| `:` | The exact commit, for every build | +| `:latest` | The newest successful build from `master` or a tag; moves on every push | +| `:vX.Y.Z` | A tagged release | + +### Deploying the image + +The repository's `deploy/deploy.sh` currently builds on the server from the +checked-out branch. That is the simplest path, and it means the deployment does +not need registry credentials. + +To deploy the image that CI actually built instead, pin it in the environment: + +```bash +ssh sw_pl_1 +cd /opt/kadence + +# Only needed if the registry package is private. +docker login git.timka.su + +export KADENCE_IMAGE=git.timka.su/timka123/kadence:v0.2.0 +docker compose -f docker-compose.yml -f docker-compose.prod.yml pull +docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --no-build + +curl -fsS https://kadence.timka.su/api/health +``` + +`--no-build` is deliberate: without it, `up` may build the local checkout and +quietly ignore the image you just pulled. The production overlay keeps the app on +`127.0.0.1` and leaves TLS to nginx; see `docker-compose.prod.yml` and +`deploy/nginx/kadence.timka.su.conf`. + +For a fully pinned deployment, use the digest from step 4 instead of the version +tag: + +```bash +export KADENCE_IMAGE=git.timka.su/timka123/kadence@sha256: +``` + +### Before you roll a release + +Take a database backup. The backend uses `create_all` rather than a migration +tool, so it adds tables and columns but never removes or rewrites them. Rolling an +old application back against a newer schema is only safe if the change was +additive: + +```bash +cd /opt/kadence +docker compose -f docker-compose.yml -f docker-compose.prod.yml exec -T db \ + sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' \ + > "backup-$(date +%F-%H%M).sql" +``` + +## The Android APK + +### What the job does + +`android-release.yml`: + +1. checks out the tag or commit; +2. installs JDK 21 and downloads the Glyph Matrix AAR from Nothing; +3. installs the Android SDK (`platforms;android-37.0`, build tools, platform + tools) and restores the Gradle caches; +4. runs `:app`'s JVM/Robolectric tests; +5. builds `assembleRelease` with the version code from the workflow run number; +6. verifies the signing certificate with `apksigner` and rejects a debug key; +7. writes `update.json` (version, commit, size, SHA-256 and signer digest); +8. uploads `kadence-release-apk` and `kadence-update-metadata`, and on a `v*` + tag attaches the APK to the Gitea release as `kadence-.apk`. + +The build is signed from the keystore secrets. `KADENCE_REQUIRE_SIGNING` is set +when a keystore was found, so a missing password or wrong alias fails the job +rather than producing a debug-signed "release". + +### Version code and version name + +`versionCode` comes from `${{ gitea.run_number }}`, so successive CI builds can +install over each other. That is the number Android compares when updating. + +`versionName` is resolved before the build: + +| Trigger | `versionName` | +| --- | --- | +| Tag `v0.2.0` | `0.2.0` (the leading `v` is dropped) | +| Push to `master` | `0.1.0-master.` | + +So a tagged release reports its tag in Settings, while a master build is +identifiable as a CI build and still has a unique, monotonic `versionCode`. + +### Installing the artifact + +From the Actions run page, download `kadence-release-apk.zip`, unpack it, and +install the `app-release.apk` inside: + +```bash +adb install -r app-release.apk +``` + +Or use the release asset on a tagged build: + +```bash +curl -fL -o kadence-v0.2.0.apk \ + https://git.timka.su/timka123/kadence/releases/download/v0.2.0/kadence-v0.2.0.apk +adb install -r kadence-v0.2.0.apk +``` + +The phone must already have the app installed with the same signing key. The +debug build uses a different application id (`.debug`) and can sit beside the +release build, but it cannot be upgraded into it. + +### Over-the-air updates + +The Android app can update itself from these artifacts, without the phone ever +holding a Gitea credential. The repository is private, so the backend reads the +newest successful build from `GITEA_UPDATE_BRANCH` and re-serves it behind the +same `/api` auth as everything else. + +Configure the backend (compose passes these through from `.env`): + +| Variable | What for | +| --- | --- | +| `GITEA_TOKEN` | read-only token that can see this repository's Actions artifacts. Empty disables OTA: the endpoints answer `503` and the app never offers an update | +| `GITEA_REPO` | `owner/repository`, default `timka123/kadence` | +| `GITEA_URL` | Gitea base URL, default `https://git.timka.su` | +| `GITEA_UPDATE_BRANCH` | branch whose successful builds are offered, default `master` | + +The token only needs repository read access; it is never sent to the phone. The +two endpoints are: + +* `GET /api/updates/android` — `update.json` for the newest successful build, + with `run_number` filled in from Gitea even for older artifacts. +* `GET /api/updates/android/apk` — the APK itself, streamed with + `Cache-Control: no-store`. + +A missing token is `503`, no usable artifact is `404`, and a Gitea failure is +`502`. The app treats background failures quietly; Settings → Updates shows the +reason. + +On the phone: + +* checks run on resume, at most once every six hours, in release builds only, + and only after onboarding; +* only a strictly higher `version_code` is offered, and a skipped build stays + skipped until **Check now** in Settings; +* the APK is downloaded only after the user asks, into the app cache, with a + `.part` file so an interrupted transfer cannot look installable; +* before handing it to Android, the app checks the package name, the version + code and that the APK's signing certificate matches the installed app. A + mismatch is deleted and reported instead of producing "App not installed"; +* Android asks for "install unknown apps" permission the first time, then its + own installer confirms the upgrade. + +This is why the release keystore matters: an APK signed with a different key +cannot upgrade an install, and the app refuses it before downloading. Debug +builds have OTA disabled entirely. + +### Artifact retention + +Gitea's artifact retention is finite (the instance's configured default, often +90 days). The release asset attached to a tag is not subject to the same +retention as a workflow artifact; that is why tagged builds are the production +path and plain `master` artifacts are only a convenience. + +## The desktop agent + +There is no desktop build in `.gitea/workflows` today. A production desktop +"build" is still the checkout plus the installer script: + +```bash +git checkout v0.2.0 +./desktop/packaging/install-desktop.sh +``` + +The app is plain Python with PyQt6, run by the system interpreter. The launcher +installs a `.pth` file pointing at the checkout, so upgrading means checking out +the new tag and re-running the installer. The privileged helper is separate and +needs its own install: + +```bash +sudo ./desktop/helper/install.sh +``` + +If you need a downloadable desktop artifact, build a wheel from the same tag: + +```bash +cd desktop +python3 -m venv /tmp/kadence-build +/tmp/kadence-build/bin/pip install build +/tmp/kadence-build/bin/python -m build +# dist/kadence_desktop-0.2.0-py3-none-any.whl +``` + +That wheel contains the Python package and QML files, not the privileged helper +or the Qt system dependency. It is not a replacement for +`install-desktop.sh` on a machine that needs the helper and menu entry. + +## Versioning and rollback + +| Thing | Value | Where it comes from | +| --- | --- | --- | +| Image release tag | `v0.2.0` | Git tag, verbatim | +| Image commit tag | first 7 characters of `HEAD` | `git rev-parse --short HEAD` | +| Image `latest` | newest successful `master` build | `docker.yml` | +| APK `versionCode` | workflow run number | `gitea.run_number` | +| APK `versionName` | tag without `v`, or `0.1.0-master.` | `Resolve the version name` step | +| APK release asset | `kadence-v0.2.0.apk` | Git tag | + +To roll back the backend, redeploy the previous version tag or digest: + +```bash +export KADENCE_IMAGE=git.timka.su/timka123/kadence:v0.1.3 +docker compose -f docker-compose.yml -f docker-compose.prod.yml pull +docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --no-build +``` + +Check `/api/health` and the app log afterwards. If the newer release added a +column the old code does not understand, restore the database backup taken before +the upgrade; `create_all` does not undo schema changes. + +To roll back the phone, install the previous release APK over the current one. +Android refuses a lower `versionCode` during a normal install; `adb install -r -d` +allows it on a device where downgrades are permitted, but that is not a reliable +production path. Keep the previous APK and the matching keystore, or bump the +version code and rebuild. + +## Downloading artifacts without the web UI + +The run page in Gitea is the simplest route: **Actions → run → Artifacts**. +For a tagged release, the attached asset is under **Releases**. + +With a personal access token, the API works too. Listing the artifacts of the +latest successful `android` run: + +```bash +TOKEN=... # a Gitea token with repository read access +REPO=https://git.timka.su/api/v1/repos/timka123/kadence + +curl -fsSL -H "Authorization: token $TOKEN" \ + "$REPO/actions/artifacts?name=kadence-release-apk" \ + | jq -r '.artifacts[] | select(.expired == false) | "\(.id) \(.created_at)"' \ + | sort -k2 | tail -1 +``` + +Then download and unpack the chosen artifact id: + +```bash +ARTIFACT_ID=... +curl -fsSL -H "Authorization: token $TOKEN" \ + "$REPO/actions/artifacts/$ARTIFACT_ID/zip" -o kadence-release-apk.zip +unzip -o kadence-release-apk.zip +``` + +For a tagged build, the release asset can be fetched by tag instead: + +```bash +curl -fsSL -H "Authorization: token $TOKEN" \ + "$REPO/releases/tags/v0.2.0" \ + | jq -r '.assets[] | select(.name | startswith("kadence-")) | .browser_download_url' +``` + +Artifacts from an older run can be expired; the release asset is the durable +copy. + +## Troubleshooting + +| Symptom | Likely cause | +| --- | --- | +| The run sits queued forever | No online runner claims `ubuntu-latest`, or the runner is registered at a scope that does not include this repository | +| `docker login` step warns that there is no `REGISTRY_TOKEN` | The secret is missing, empty, or lacks `package:write`; the image is built but not pushed | +| `Cannot connect to the Docker daemon` in `docker.yml` | The job container does not have the host socket; check `docker_host` and `valid_volumes` in `deploy/act-runner.yaml` | +| The Docker push returns 401 | `REGISTRY_TOKEN` is a job token or a PAT without `package:write`, or the username does not match the token | +| Android build fails before Gradle starts | The SDK install failed; check `android/ci/install-sdk.sh` and the cache key. A cold cache can take several minutes | +| The APK is debug-signed despite the keystore secrets | At least one of `KADENCE_KEYSTORE_BASE64`, `KADENCE_KEYSTORE_PASSWORD`, `KADENCE_KEY_ALIAS`, `KADENCE_KEY_PASSWORD` is missing or wrong. With `KADENCE_REQUIRE_SIGNING` the job should have failed; fix the secret rather than shipping it | +| `kadence-release-apk` is missing from the run | The build failed before the upload step, or the artifact has expired | +| The tag build cannot attach the APK to a release | The tag was pushed but no release exists yet, or the built-in `GITEA_TOKEN` lacks permission. Create the release, or check the repository's Actions token permissions | +| A tag push did not start a workflow | The tag does not match `v*`, or it was created locally and never pushed: `git push origin v0.2.0` | +| The deployment still runs old code after a pull | `docker compose up` rebuilt the local checkout. Use `--no-build` and pin `KADENCE_IMAGE` | +| Android installs reject the new APK | It is signed with a different key, or has a lower version code than the installed one. Keep the release keystore; do not switch it between releases | + +### Secrets are not in the build logs + +The workflows never echo a token or a keystore password. The APK job does print +the certificate with `apksigner --print-certs`, which is the public certificate, +not the private key. Do not add a debug step that prints the environment, the +decoded keystore, or the registry token.