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
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:
25 files changed
+2353
-4
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user