implement ota updates
lint / ruff (push) Successful in 14s
tests / desktop (push) Successful in 22s
docker / image (push) Successful in 43s
tests / web (push) Successful in 39s
tests / android-core (push) Successful in 1m1s
tests / backend (push) Successful in 2m12s
android / apk (push) Successful in 6m50s

This commit is contained in:
timka123 committed 2026-10-22 21:11:04 +03:00
1 parent 61529be465
commit 1bdd65cc76
25 files changed
+2353 -4

No files matched your search

+10
View File
@@ -27,6 +27,16 @@ SCHEDULER_ENABLED=true
TELEGRAM_BOT_ENABLED=true TELEGRAM_BOT_ENABLED=true
LOG_LEVEL=INFO 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) ----------------- # ---- 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 # 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, # either of these and the login screen appears — the desktop app is untouched,
+40
View File
@@ -74,6 +74,16 @@ jobs:
printf '%s' "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/kadence.keystore" printf '%s' "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/kadence.keystore"
echo "signed=true" >> "$GITHUB_OUTPUT" 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 - name: Build the release APK
working-directory: android working-directory: android
env: env:
@@ -103,12 +113,42 @@ jobs:
fi fi
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 - uses: actions/upload-artifact@v3
with: with:
name: kadence-release-apk name: kadence-release-apk
path: android/app/build/outputs/apk/release/app-release.apk path: android/app/build/outputs/apk/release/app-release.apk
if-no-files-found: error 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 - name: Attach it to the release, when this is a tag
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
env: env:
+7 -3
View File
@@ -191,6 +191,7 @@ and you can fill everything in from **Settings**.
| `SCHEDULER_ENABLED`, `TELEGRAM_BOT_ENABLED` | switch the background jobs / bot off | | `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) | | `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 | | `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, * `backend/` — FastAPI app. `app/services/` holds the thinking (timer, tasks,
stats, report, blocker policy, scheduler, bot); `app/sources/` holds one module 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 * `frontend/` — React + TypeScript + MUI, built by Vite into the backend image and
served from the same origin (so there is no CORS to configure). served from the same origin (so there is no CORS to configure).
* **The look** — warm paper, ink type, hairline rules instead of boxes and * **The look** — warm paper, ink type, hairline rules instead of boxes and
@@ -270,14 +271,17 @@ Gitea Actions, in `.gitea/workflows/`:
| Workflow | When | What | | 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` | | `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 `:<short-sha>` | | `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 `:<short-sha>` |
| `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** — **Nothing runs until a docker-mode runner is registered** —
[`docs/gitea-runner.md`](docs/gitea-runner.md) is the guide, and [`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. `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: Short version, on the runner host:
```bash ```bash
+19
View File
@@ -12,6 +12,12 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- OTA: the app downloads its own release APK from the backend's Gitea
proxy and hands it to Android's installer. The user still grants
"install unknown apps" and confirms the install; this permission only
makes the request possible. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- The countdown notification, and the alarm when an interval ends. Runtime <!-- The countdown notification, and the alarm when an interval ends. Runtime
permission since Android 13; the timer screen asks for it. --> permission since Android 13; the timer screen asks for it. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
@@ -81,6 +87,19 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Kadence"> android:theme="@style/Theme.Kadence">
<!-- The downloaded APK lives in the cache directory. FileProvider is
what gives Android's package installer a readable URI without
exposing the whole filesystem or using a file:// URI. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.updates"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/update_file_paths" />
</provider>
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@@ -38,6 +38,7 @@ import tech.kadence.android.glyph.newGlyphDisplay
import tech.kadence.android.notify.Notifications import tech.kadence.android.notify.Notifications
import tech.kadence.android.service.TimerService import tech.kadence.android.service.TimerService
import tech.kadence.android.timer.TimerEngine import tech.kadence.android.timer.TimerEngine
import tech.kadence.android.update.UpdateManager
import tech.kadence.core.IntervalFacts import tech.kadence.core.IntervalFacts
private const val TAG = "Kadence" private const val TAG = "Kadence"
@@ -72,6 +73,10 @@ class KadenceApplication : Application() {
lateinit var blocker: BlockerEngine lateinit var blocker: BlockerEngine
private set private set
/** OTA checks and downloads. Deliberately not started in the background. */
lateinit var updates: UpdateManager
private set
/** /**
* One bound matrix for app-driven frames. * One bound matrix for app-driven frames.
* *
@@ -93,6 +98,12 @@ class KadenceApplication : Application() {
// in Settings takes effect on the next call instead of needing a restart. // in Settings takes effect on the next call instead of needing a restart.
api = KadenceApi(config = { prefs.current }) api = KadenceApi(config = { prefs.current })
repo = KadenceRepository(api) repo = KadenceRepository(api)
updates = UpdateManager(
context = this,
repo = repo,
prefs = prefs,
scope = scope,
)
engine = TimerEngine( engine = TimerEngine(
repo = repo, repo = repo,
scope = scope, scope = scope,
@@ -37,6 +37,9 @@ class MainActivity : ComponentActivity() {
repeatOnLifecycle(Lifecycle.State.RESUMED) { repeatOnLifecycle(Lifecycle.State.RESUMED) {
app.engine.refreshNow() app.engine.refreshNow()
app.blocker.refreshNow() app.blocker.refreshNow()
// Throttled inside the manager: resuming the app repeatedly does
// not mean repeatedly asking Gitea through the backend.
app.updates.check()
} }
} }
@@ -16,8 +16,10 @@
*/ */
package tech.kadence.android.data package tech.kadence.android.data
import java.io.File
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import tech.kadence.android.data.dto.AndroidUpdateDto
import tech.kadence.android.data.dto.AuthMeDto import tech.kadence.android.data.dto.AuthMeDto
import tech.kadence.android.data.dto.BlockDefaultsDto import tech.kadence.android.data.dto.BlockDefaultsDto
import tech.kadence.android.data.dto.BlockEventsDto import tech.kadence.android.data.dto.BlockEventsDto
@@ -42,6 +44,17 @@ class KadenceRepository(val api: KadenceApi) {
suspend fun authMe(): ApiResult<AuthMeDto> = api.get("/api/auth/me") suspend fun authMe(): ApiResult<AuthMeDto> = api.get("/api/auth/me")
// ---- updates ------------------------------------------------------------
/** The newest successful master build, as re-served by this backend. */
suspend fun androidUpdate(): ApiResult<AndroidUpdateDto> = api.get("/api/updates/android")
/** Download the APK behind [androidUpdate]. */
suspend fun downloadAndroidUpdate(
target: File,
onProgress: (Long, Long?) -> Unit = { _, _ -> },
): ApiResult<Unit> = api.download("/api/updates/android/apk", target, onProgress)
// ---- timer -------------------------------------------------------------- // ---- timer --------------------------------------------------------------
suspend fun timerState(): ApiResult<TimerStateDto> = api.get("/api/timer/state") suspend fun timerState(): ApiResult<TimerStateDto> = api.get("/api/timer/state")
@@ -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)
}
@@ -24,6 +24,7 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import java.io.File
import java.io.IOException import java.io.IOException
import java.util.concurrent.TimeUnit 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<Unit> = 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 <reified T> get(path: String): ApiResult<T> = decode(request("GET", path)) suspend inline fun <reified T> get(path: String): ApiResult<T> = decode(request("GET", path))
suspend inline fun <reified T> post(path: String, body: String? = null): ApiResult<T> = suspend inline fun <reified T> post(path: String, body: String? = null): ApiResult<T> =
@@ -60,6 +60,21 @@ class KadencePrefs(context: Context) {
onboarded = prefs.getBoolean(KEY_ONBOARDED, false), 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) { fun update(transform: (KadenceConfig) -> KadenceConfig) {
val next = transform(_config.value) val next = transform(_config.value)
prefs.edit().apply { prefs.edit().apply {
@@ -88,6 +103,8 @@ class KadencePrefs(context: Context) {
private const val KEY_DYNAMIC_ACCENT = "dynamic_accent" private const val KEY_DYNAMIC_ACCENT = "dynamic_accent"
private const val KEY_ALARM = "alarm_on_complete" private const val KEY_ALARM = "alarm_on_complete"
private const val KEY_ONBOARDED = "onboarded" 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 { fun normalizeBaseUrl(raw: String): String {
val trimmed = raw.trim().trimEnd('/') val trimmed = raw.trim().trimEnd('/')
@@ -50,6 +50,9 @@ import tech.kadence.android.ui.settings.SettingsScreen
import tech.kadence.android.ui.tasks.TasksScreen import tech.kadence.android.ui.tasks.TasksScreen
import tech.kadence.android.ui.theme.KadenceTheme import tech.kadence.android.ui.theme.KadenceTheme
import tech.kadence.android.ui.timer.TimerScreen 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) { private enum class Destination(val label: String) {
Timer("Timer"), Timer("Timer"),
@@ -63,8 +66,10 @@ private enum class Destination(val label: String) {
fun KadenceApp(app: KadenceApplication) { fun KadenceApp(app: KadenceApplication) {
val config by app.prefs.config.collectAsStateWithLifecycle() val config by app.prefs.config.collectAsStateWithLifecycle()
val ui by app.engine.state.collectAsStateWithLifecycle() val ui by app.engine.state.collectAsStateWithLifecycle()
val updateState by app.updates.state.collectAsStateWithLifecycle()
var showOnboarding by remember { mutableStateOf(!config.onboarded) } var showOnboarding by remember { mutableStateOf(!config.onboarded) }
var showUpdateDialog by remember { mutableStateOf(false) }
var destination by remember { mutableStateOf(Destination.Timer) } var destination by remember { mutableStateOf(Destination.Timer) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -104,6 +109,7 @@ fun KadenceApp(app: KadenceApplication) {
// it is, ask straight away rather than waiting out the interval. // it is, ask straight away rather than waiting out the interval.
app.engine.refreshNow() app.engine.refreshNow()
app.blocker.refreshNow() app.blocker.refreshNow()
app.updates.check()
}, },
) )
} }
@@ -111,6 +117,16 @@ fun KadenceApp(app: KadenceApplication) {
} }
Column(Modifier.fillMaxSize().statusBarsPadding()) { 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)) { Box(Modifier.weight(1f)) {
when (destination) { when (destination) {
Destination.Timer -> TimerScreen( Destination.Timer -> TimerScreen(
@@ -177,6 +193,9 @@ fun KadenceApp(app: KadenceApplication) {
} }
}, },
onReconnect = { showOnboarding = true }, onReconnect = { showOnboarding = true },
updateManager = app.updates,
updateState = updateState,
onOpenUpdate = { showUpdateDialog = true },
) )
} }
} }
@@ -188,6 +207,14 @@ fun KadenceApp(app: KadenceApplication) {
modifier = Modifier.navigationBarsPadding(), modifier = Modifier.navigationBarsPadding(),
) )
} }
if (showUpdateDialog) {
UpdateDialog(
manager = app.updates,
state = updateState,
onDismiss = { showUpdateDialog = false },
)
}
} }
} }
@@ -46,6 +46,10 @@ import tech.kadence.android.ui.components.Panel
import tech.kadence.android.ui.components.SectionTitle import tech.kadence.android.ui.components.SectionTitle
import tech.kadence.android.ui.components.ToggleRow import tech.kadence.android.ui.components.ToggleRow
import tech.kadence.android.ui.theme.KadenceTheme 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 @Composable
fun SettingsScreen( fun SettingsScreen(
@@ -54,6 +58,9 @@ fun SettingsScreen(
onUpdate: ((KadenceConfig) -> KadenceConfig) -> Unit, onUpdate: ((KadenceConfig) -> KadenceConfig) -> Unit,
onSetTimerEnabled: (Boolean) -> Unit, onSetTimerEnabled: (Boolean) -> Unit,
onReconnect: () -> Unit, onReconnect: () -> Unit,
updateManager: UpdateManager,
updateState: UpdateState,
onOpenUpdate: () -> Unit,
) { ) {
val colors = KadenceTheme.colors val colors = KadenceTheme.colors
val context = LocalContext.current 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") SectionTitle("Appearance")
Panel(padding = 0.dp) { Panel(padding = 0.dp) {
ToggleRow( 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. */ /** Deep-link straight to Kadence's notification page, not the global one. */
private fun openAppNotificationSettings(context: Context) { private fun openAppNotificationSettings(context: Context) {
val intent = Intent(AndroidSettings.ACTION_APP_NOTIFICATION_SETTINGS) val intent = Intent(AndroidSettings.ACTION_APP_NOTIFICATION_SETTINGS)
@@ -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"
}
@@ -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<String> = 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<String>): 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<String> {
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()
}
@@ -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>(UpdateState.Idle)
val state: StateFlow<UpdateState> = _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()
}
}
}
}
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Only the OTA directory is shared, and only through FileProvider. The APK
is downloaded into cacheDir/updates by UpdateManager. -->
<paths>
<cache-path name="updates" path="updates/" />
</paths>
@@ -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<UpdateState.Available>(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<UpdateState.UpToDate>(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<UpdateState.Available>(
await(manager) { it !is UpdateState.Checking },
)
manager.skip()
assertIs<UpdateState.Idle>(manager.state.value)
assertEquals(9L, prefs.skippedUpdateVersionCode)
clock += 7 * 60 * 60 * 1000L
manager.check()
assertIs<UpdateState.Idle>(await(manager) { it is UpdateState.Idle })
assertEquals(2, server.requestCount)
manager.check(force = true)
val forced = assertIs<UpdateState.Available>(
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<UpdateState.Failed>(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<UpdateState.Failed>(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<UpdateState.Failed>(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<UpdateState.Idle>(manager.state.value)
assertEquals(0, server.requestCount)
}
}
+2
View File
@@ -8,6 +8,7 @@ from app.api import (
routes_settings, routes_settings,
routes_tasks, routes_tasks,
routes_timer, routes_timer,
routes_updates,
) )
api_router = APIRouter() 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_blocker.router)
api_router.include_router(routes_reports.router) api_router.include_router(routes_reports.router)
api_router.include_router(routes_settings.router) api_router.include_router(routes_settings.router)
api_router.include_router(routes_updates.router)
__all__ = ["api_router"] __all__ = ["api_router"]
+53
View File
@@ -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"},
)
+24
View File
@@ -55,6 +55,30 @@ class EnvSettings(BaseSettings):
default=True, default=True,
description="Run the Telegram command bot when a token is configured.", 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( api_token: str = Field(
default="", default="",
description=( description=(
+373
View File
@@ -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
+202
View File
@@ -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"]
+7
View File
@@ -46,6 +46,13 @@ services:
TELEGRAM_BOT_ENABLED: ${TELEGRAM_BOT_ENABLED:-true} TELEGRAM_BOT_ENABLED: ${TELEGRAM_BOT_ENABLED:-true}
LOG_LEVEL: ${LOG_LEVEL:-INFO} 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. # Web login — see .env.example for what each does.
AUTH_USERNAME: ${AUTH_USERNAME:-timka} AUTH_USERNAME: ${AUTH_USERNAME:-timka}
AUTH_PASSWORD: ${AUTH_PASSWORD:-} AUTH_PASSWORD: ${AUTH_PASSWORD:-}
+3 -1
View File
@@ -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, 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 ### What the pieces do
+449
View File
@@ -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:<short-sha>` and `:latest`; on a tag also `:<version>` |
| `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-<version>.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 |
| --- | --- |
| `:<short-sha>` | 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:<digest>
```
### 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-<version>.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.<run_number>` |
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.<run_number>` | `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.