Update install failures become visible: blocked, aborted, failed (D-155)

The PackageInstaller session's final status now reaches UpdateRepository:
InstallResultReceiver hands it by session id to the waiting AndroidApkInstaller
call (ApkInstaller.install returns a PendingInstall). installOutcome() maps
STATUS_FAILURE_BLOCKED to UpdateFailure.Blocked, STATUS_FAILURE_ABORTED back to
Available without an error, and every other failure to UpdateFailure.Install
with the installer's EXTRA_STATUS_MESSAGE. The cached APK is deleted once the
installer answers, so "Try again" downloads again. UpdateBanner's Failed variant
shows the reason on S-4 and S-22; two core:design strings (en/sv) with their
docs/ux/strings.csv rows. Unit tests for the mapping, the repository outcomes
and the receiver; goldens: update_banner re-recorded, S-17-install-blocked,
S-17-install-failed and s22_about_install_blocked new.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-21 21:20:12 +02:00
parent 1c9856bfba
commit d686ee0120
25 changed files with 327 additions and 27 deletions
@@ -9,6 +9,7 @@ import android.util.Log
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.readAvailable
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -21,19 +22,76 @@ import se.hagstedt.kvitt.data.api.KvittApi
import se.hagstedt.kvitt.data.repo.PhonePrefs
import se.hagstedt.kvitt.data.util.Clock
import se.hagstedt.kvitt.model.UpdateCheck
import se.hagstedt.kvitt.model.UpdateFailure
import se.hagstedt.kvitt.model.UpdateInfo
import se.hagstedt.kvitt.model.UpdateRepository
import se.hagstedt.kvitt.model.UpdateState
import java.io.File
import java.io.IOException
import java.security.MessageDigest
import java.util.concurrent.ConcurrentHashMap
/** The `PackageInstaller` call, isolated so the updater's logic is unit-tested without a device. */
interface ApkInstaller {
/** Whether "Install unknown apps" is allowed for Kvitt. */
fun canRequestPackageInstalls(): Boolean
suspend fun install(apk: File)
/**
* Copies [apk] into an install session and commits it; returns once committed. The system's confirmation and the
* install itself follow; [PendingInstall.result] waits for the installer's final answer (D-155).
*/
suspend fun install(apk: File): PendingInstall
}
/** A committed install session (D-155). */
fun interface PendingInstall {
/** Suspends until the installer reports a final status. */
suspend fun result(): InstallOutcome
}
/** The final answer of a `PackageInstaller` session (D-155). */
sealed interface InstallOutcome {
/** Installed; for Kvitt's own update the process is replaced, so this is rarely seen. */
data object Success : InstallOutcome
/** The user declined ("Avbryt" in the system dialog) or the session was abandoned: not an error. */
data object Aborted : InstallOutcome
/** Refused by a device policy or a package verifier (the phone's security settings). */
data object Blocked : InstallOutcome
/** Any other failure; [message] is the installer's `EXTRA_STATUS_MESSAGE`, null when it gave none. */
data class Failed(val message: String?) : InstallOutcome
}
/**
* `PackageInstaller.EXTRA_STATUS` to [InstallOutcome] (D-155); null for `STATUS_PENDING_USER_ACTION`, which is not final.
* Every status other than success, pending, aborted and blocked (generic, invalid, conflict, storage, incompatible,
* timeout and any later addition) is [InstallOutcome.Failed]; a blank message counts as none.
*/
fun installOutcome(status: Int, message: String?): InstallOutcome? = when (status) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> null
PackageInstaller.STATUS_SUCCESS -> InstallOutcome.Success
PackageInstaller.STATUS_FAILURE_ABORTED -> InstallOutcome.Aborted
PackageInstaller.STATUS_FAILURE_BLOCKED -> InstallOutcome.Blocked
else -> InstallOutcome.Failed(message?.trim()?.takeIf { it.isNotEmpty() })
}
/**
* Hands each session's final status from [InstallResultReceiver] to the [AndroidApkInstaller] call waiting for it,
* by session id. A result nobody waits for (the process was restarted in between) is only logged.
*/
internal object InstallResults {
private val waiting = ConcurrentHashMap<Int, CompletableDeferred<InstallOutcome>>()
fun expect(sessionId: Int): CompletableDeferred<InstallOutcome> = CompletableDeferred<InstallOutcome>().also { waiting[sessionId] = it }
fun forget(sessionId: Int) {
waiting.remove(sessionId)
}
/** Whether someone was waiting for [sessionId]. */
fun complete(sessionId: Int, outcome: InstallOutcome): Boolean = waiting.remove(sessionId)?.complete(outcome) ?: false
}
/** Size and SHA-256 of a downloaded APK against `latest.json` (AC-16.3). */
@@ -60,7 +118,9 @@ object ApkVerifier {
* FR-16. Release builds check `GET /apk/latest` on cold start and at most every 6 hours while resumed (`:app` calls
* [check] on both); S-22's button calls [checkNow], which always asks (D-140). A newer `version_code` is [UpdateState.Available]. [install] explains "Install unknown apps" once
* before the first install, downloads to the cache, verifies size and SHA-256, deletes the file on any failure
* ("Download failed", AC-16.4) and installs through [ApkInstaller]. Debug builds never check (AC-16.5).
* ("Download failed", AC-16.4) and installs through [ApkInstaller]. The installer's final answer (D-155): declined is
* [UpdateState.Available] again, blocked or failed is [UpdateState.Failed] with the reason; the file is deleted once
* the installer has answered, so "Försök igen" downloads again. Debug builds never check (AC-16.5).
*/
class UpdateRepositoryImpl(
private val api: KvittApi,
@@ -148,8 +208,33 @@ class UpdateRepositoryImpl(
mutable.value = UpdateState.Failed(info)
return
}
installer.install(file)
mutable.value = UpdateState.Available(info)
try {
val pending = try {
installer.install(file)
} catch (e: IOException) {
Log.w(TAG, "install session failed: ${e.javaClass.simpleName}")
mutable.value = UpdateState.Failed(info, UpdateFailure.Install(null))
return
} catch (e: SecurityException) {
Log.w(TAG, "install session refused: ${e.javaClass.simpleName}")
mutable.value = UpdateState.Failed(info, UpdateFailure.Install(null))
return
}
mutable.value = UpdateState.Available(info)
val outcome = pending.result()
Log.i(TAG, "install finished: ${outcome.javaClass.simpleName}")
mutable.value = when (outcome) {
InstallOutcome.Success -> {
latest = null
UpdateState.None
}
InstallOutcome.Aborted -> UpdateState.Available(info)
InstallOutcome.Blocked -> UpdateState.Failed(info, UpdateFailure.Blocked)
is InstallOutcome.Failed -> UpdateState.Failed(info, UpdateFailure.Install(outcome.message))
}
} finally {
file.delete()
}
}
private suspend fun copy(channel: ByteReadChannel, file: File, expected: Long, info: UpdateInfo) = withContext(Dispatchers.IO) {
@@ -186,33 +271,64 @@ class UpdateRepositoryImpl(
class AndroidApkInstaller(private val context: Context) : ApkInstaller {
override fun canRequestPackageInstalls(): Boolean = context.packageManager.canRequestPackageInstalls()
override suspend fun install(apk: File) = withContext(Dispatchers.IO) {
override suspend fun install(apk: File): PendingInstall = withContext(Dispatchers.IO) {
val installer = context.packageManager.packageInstaller
val sessionId = installer.createSession(PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL))
installer.openSession(sessionId).use { session ->
apk.inputStream().use { input ->
session.openWrite("kvitt.apk", 0, apk.length()).use { output ->
input.copyTo(output)
session.fsync(output)
// Registered before the commit: the answer can arrive before this call returns.
val answer = InstallResults.expect(sessionId)
try {
installer.openSession(sessionId).use { session ->
apk.inputStream().use { input ->
session.openWrite("kvitt.apk", 0, apk.length()).use { output ->
input.copyTo(output)
session.fsync(output)
}
}
val intent = Intent(context, InstallResultReceiver::class.java)
// Mutable: the system adds the session status and the confirmation intent to it.
val pending = PendingIntent.getBroadcast(context, sessionId, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE)
session.commit(pending.intentSender)
}
} catch (e: IOException) {
InstallResults.forget(sessionId)
installer.abandonSession(sessionId)
throw e
} catch (e: SecurityException) {
InstallResults.forget(sessionId)
installer.abandonSession(sessionId)
throw e
}
PendingInstall {
try {
answer.await()
} finally {
InstallResults.forget(sessionId)
}
val intent = Intent(context, InstallResultReceiver::class.java)
// Mutable: the system adds the session status and the confirmation intent to it.
val pending = PendingIntent.getBroadcast(context, sessionId, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE)
session.commit(pending.intentSender)
}
}
}
/** Starts the system's install confirmation when the session needs the user (first install, unknown sources). */
/**
* The session's status broadcasts: starts the system's install confirmation when the session needs the user, and hands
* every final status to the waiting [AndroidApkInstaller] call (D-155). Logs only the status code, never the message.
*/
class InstallResultReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)?.let {
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
val outcome = installOutcome(status, intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE))
if (outcome == null) {
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)?.let {
context.startActivity(it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
PackageInstaller.STATUS_SUCCESS -> Unit
else -> Log.w("KvittUpdate", "install finished with status $status")
return
}
val sessionId = intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, NO_SESSION)
val delivered = InstallResults.complete(sessionId, outcome)
if (outcome != InstallOutcome.Success) Log.w(TAG, "install finished with status $status (waiting: $delivered)")
}
private companion object {
const val NO_SESSION = -1
const val TAG = "KvittUpdate"
}
}
@@ -1,5 +1,8 @@
package se.hagstedt.kvitt.data.update
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import io.ktor.http.HttpStatusCode
import kotlinx.coroutines.flow.first
import org.junit.After
@@ -10,12 +13,14 @@ import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import se.hagstedt.kvitt.data.Harness
import se.hagstedt.kvitt.data.api.ApkLatestDto
import se.hagstedt.kvitt.data.api.KvittJson
import se.hagstedt.kvitt.data.runSync
import se.hagstedt.kvitt.model.UpdateCheck
import se.hagstedt.kvitt.model.UpdateFailure
import se.hagstedt.kvitt.model.UpdateInfo
import se.hagstedt.kvitt.model.UpdateState
import java.io.File
@@ -31,15 +36,27 @@ class UpdaterTest {
private val installer = FakeInstaller()
private val apk = ByteArray(200_000) { (it % 251).toByte() }
/** Records each committed APK and answers with [outcome], as the installer's final broadcast would. */
class FakeInstaller : ApkInstaller {
var allowed = true
var outcome: InstallOutcome = InstallOutcome.Success
val installed = mutableListOf<File>()
val bytes = mutableListOf<ByteArray>()
/** The repository's state at the moment the installer answers (the system dialog is up). */
var stateWhileWaiting: UpdateState? = null
var observe: () -> UpdateState? = { null }
override fun canRequestPackageInstalls() = allowed
override suspend fun install(apk: File) {
override suspend fun install(apk: File): PendingInstall {
assertTrue(apk.exists())
installed += apk
bytes += apk.readBytes()
return PendingInstall {
stateWhileWaiting = observe()
outcome
}
}
}
@@ -65,7 +82,22 @@ class UpdaterTest {
private fun apkFile(version: Int) = File(File(cache, UpdateRepositoryImpl.APK_DIR), "kvitt-$version.apk")
private fun repo(debug: Boolean = false) = UpdateRepositoryImpl(h.api, cache, installer, h.prefs, h.clock, Harness.APP_VERSION, debug)
private fun repo(debug: Boolean = false) =
UpdateRepositoryImpl(h.api, cache, installer, h.prefs, h.clock, Harness.APP_VERSION, debug).also { r -> installer.observe = { r.state.value } }
private val newer get() = UpdateInfo(Harness.APP_VERSION + 1, "2026.11.02-1a2b3c4", apk.size.toLong())
private fun downloads() = h.requests.count { it.url.encodedPath.startsWith("/apk/files/") }
/** Checks, installs once with the installer answering [outcome], and returns the repository. */
private suspend fun installWith(outcome: InstallOutcome): UpdateRepositoryImpl {
installer.outcome = outcome
serve(latest(Harness.APP_VERSION + 1))
val repo = repo()
repo.check()
repo.install()
return repo
}
@Test
fun newerVersionIsAvailable_sameIsNone() = runSync {
@@ -162,9 +194,94 @@ class UpdaterTest {
repo.check()
repo.install()
assertEquals(apkFile(Harness.APP_VERSION + 1), installer.installed.single())
assertTrue(apk.contentEquals(installer.installed.single().readBytes()))
assertTrue(apk.contentEquals(installer.bytes.single()))
assertEquals("the banner offers Update while the system dialog is up", UpdateState.Available(newer), installer.stateWhileWaiting)
}
@Test
fun statusMapping_d155() {
assertEquals(null, installOutcome(PackageInstaller.STATUS_PENDING_USER_ACTION, null))
assertEquals(InstallOutcome.Success, installOutcome(PackageInstaller.STATUS_SUCCESS, null))
assertEquals(InstallOutcome.Aborted, installOutcome(PackageInstaller.STATUS_FAILURE_ABORTED, "User rejected permissions"))
assertEquals(InstallOutcome.Blocked, installOutcome(PackageInstaller.STATUS_FAILURE_BLOCKED, "blocked by policy"))
assertEquals(InstallOutcome.Failed("conflict"), installOutcome(PackageInstaller.STATUS_FAILURE_CONFLICT, " conflict "))
assertEquals(InstallOutcome.Failed(null), installOutcome(PackageInstaller.STATUS_FAILURE, " "))
val generic = listOf(
PackageInstaller.STATUS_FAILURE,
PackageInstaller.STATUS_FAILURE_INVALID,
PackageInstaller.STATUS_FAILURE_CONFLICT,
PackageInstaller.STATUS_FAILURE_STORAGE,
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE,
PackageInstaller.STATUS_FAILURE_TIMEOUT,
UNKNOWN_STATUS,
)
generic.forEach { assertEquals("status $it", InstallOutcome.Failed(null), installOutcome(it, null)) }
}
@Test
fun blockedInstall_failsWithTheReason_andDeletesTheFile() = runSync {
val repo = installWith(InstallOutcome.Blocked)
assertEquals(UpdateState.Failed(newer, UpdateFailure.Blocked), repo.state.first())
assertFalse(apkFile(Harness.APP_VERSION + 1).exists())
}
@Test
fun abortedInstall_isAvailableAgain_withoutAnError() = runSync {
val repo = installWith(InstallOutcome.Aborted)
assertEquals(UpdateState.Available(newer), repo.state.first())
assertFalse(apkFile(Harness.APP_VERSION + 1).exists())
}
@Test
fun genericInstallFailure_carriesTheInstallersMessage() = runSync {
val message = "INSTALL_FAILED_UPDATE_INCOMPATIBLE: signatures do not match"
val repo = installWith(InstallOutcome.Failed(message))
assertEquals(UpdateState.Failed(newer, UpdateFailure.Install(message)), repo.state.first())
assertFalse(apkFile(Harness.APP_VERSION + 1).exists())
installer.outcome = InstallOutcome.Failed(null)
repo.install()
assertEquals(UpdateState.Failed(newer, UpdateFailure.Install(null)), repo.state.first())
}
@Test
fun successfulInstall_clearsTheBanner_andDeletesTheFile() = runSync {
val repo = installWith(InstallOutcome.Success)
assertEquals(UpdateState.None, repo.state.first())
assertFalse(apkFile(Harness.APP_VERSION + 1).exists())
}
@Test
fun tryAgainAfterAnInstallFailure_downloadsAgain() = runSync {
val repo = installWith(InstallOutcome.Blocked)
assertEquals(1, downloads())
installer.outcome = InstallOutcome.Aborted
repo.install()
assertEquals(2, downloads())
assertEquals(2, installer.installed.size)
assertEquals(UpdateState.Available(newer), repo.state.first())
}
@Test
fun receiver_handsTheFinalStatusToTheWaitingSession() = runSync {
val context: Context = RuntimeEnvironment.getApplication()
val blocked = InstallResults.expect(SESSION)
InstallResultReceiver().onReceive(context, result(PackageInstaller.STATUS_FAILURE_BLOCKED, SESSION))
assertEquals(InstallOutcome.Blocked, blocked.await())
val failed = InstallResults.expect(SESSION + 1)
InstallResultReceiver().onReceive(context, result(PackageInstaller.STATUS_FAILURE_STORAGE, SESSION + 1, "INSTALL_FAILED_INSUFFICIENT_STORAGE"))
assertEquals(InstallOutcome.Failed("INSTALL_FAILED_INSUFFICIENT_STORAGE"), failed.await())
val pending = InstallResults.expect(SESSION + 2)
InstallResultReceiver().onReceive(context, result(PackageInstaller.STATUS_PENDING_USER_ACTION, SESSION + 2))
assertFalse("the confirmation is not the final answer", pending.isCompleted)
InstallResults.forget(SESSION + 2)
assertFalse("nobody waits: only logged", InstallResults.complete(SESSION + 3, InstallOutcome.Aborted))
}
private fun result(status: Int, session: Int, message: String? = null) = Intent()
.putExtra(PackageInstaller.EXTRA_STATUS, status)
.putExtra(PackageInstaller.EXTRA_SESSION_ID, session)
.apply { if (message != null) putExtra(PackageInstaller.EXTRA_STATUS_MESSAGE, message) }
@Test
fun shaMismatchDeletesTheFile_andFails() = runSync {
serve(latest(Harness.APP_VERSION + 1, sha = "0".repeat(64)))
@@ -199,5 +316,10 @@ class UpdaterTest {
assertEquals(1, installer.installed.size)
}
private companion object {
const val SESSION = 4711
const val UNKNOWN_STATUS = 99
}
private fun sha256(bytes: ByteArray) = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it.toInt() and 0xFF) }
}