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

+19
View File
@@ -12,6 +12,12 @@
<uses-permission android:name="android.permission.INTERNET" />
<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
permission since Android 13; the timer screen asks for it. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
@@ -81,6 +87,19 @@
android:supportsRtl="true"
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
android:name=".MainActivity"
android:exported="true"
@@ -38,6 +38,7 @@ import tech.kadence.android.glyph.newGlyphDisplay
import tech.kadence.android.notify.Notifications
import tech.kadence.android.service.TimerService
import tech.kadence.android.timer.TimerEngine
import tech.kadence.android.update.UpdateManager
import tech.kadence.core.IntervalFacts
private const val TAG = "Kadence"
@@ -72,6 +73,10 @@ class KadenceApplication : Application() {
lateinit var blocker: BlockerEngine
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.
*
@@ -93,6 +98,12 @@ class KadenceApplication : Application() {
// in Settings takes effect on the next call instead of needing a restart.
api = KadenceApi(config = { prefs.current })
repo = KadenceRepository(api)
updates = UpdateManager(
context = this,
repo = repo,
prefs = prefs,
scope = scope,
)
engine = TimerEngine(
repo = repo,
scope = scope,
@@ -37,6 +37,9 @@ class MainActivity : ComponentActivity() {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
app.engine.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
import java.io.File
import kotlinx.serialization.encodeToString
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.BlockDefaultsDto
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")
// ---- 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 --------------------------------------------------------------
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.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<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> post(path: String, body: String? = null): ApiResult<T> =
@@ -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('/')
@@ -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 },
)
}
}
}
@@ -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)
@@ -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)
}
}