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:
@@ -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) }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user