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
@@ -127,6 +127,7 @@ import se.hagstedt.kvitt.model.ColorToken
import se.hagstedt.kvitt.model.ExpenseEffect
import se.hagstedt.kvitt.model.Share
import se.hagstedt.kvitt.model.SplitMode
import se.hagstedt.kvitt.model.UpdateFailure
import se.hagstedt.kvitt.model.UpdateState
import kotlinx.datetime.LocalDate
import androidx.compose.ui.res.stringResource
@@ -300,6 +301,8 @@ fun UpdateBannerSheet() {
UpdateBanner(UpdateState.Available(Samples.update), online = false, {}, {}, {})
UpdateBanner(UpdateState.Downloading(Samples.update, 0.64f), online = true, {}, {}, {})
UpdateBanner(UpdateState.Failed(Samples.update), online = true, {}, {}, {})
UpdateBanner(UpdateState.Failed(Samples.update, UpdateFailure.Blocked), online = true, {}, {}, {})
UpdateBanner(UpdateState.Failed(Samples.update, UpdateFailure.Install(Samples.INSTALLER_MESSAGE)), online = true, {}, {}, {})
}
@Composable
@@ -102,6 +102,9 @@ object Samples {
val update = UpdateInfo(versionCode = 12, versionName = "1.2", sizeBytes = 8_400_000)
/** A real `EXTRA_STATUS_MESSAGE` of a signature mismatch (D-155). */
const val INSTALLER_MESSAGE = "INSTALL_FAILED_UPDATE_INCOMPATIBLE: Existing package se.hagstedt.kvitt signatures do not match newer version; ignoring!"
val ratioShares = listOf(Share(jacob.id, 20_600, 5000), Share(anna.id, 20_600, 5000))
val icaShares = listOf(Share(jacob.id, 62_380, 5000), Share(anna.id, 62_380, 5000))
@@ -38,6 +38,7 @@ import se.hagstedt.kvitt.design.format.RelativeTime
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.model.SyncSummary
import se.hagstedt.kvitt.model.UpdateFailure
import se.hagstedt.kvitt.model.UpdateState
import kotlin.time.Instant
@@ -184,7 +185,9 @@ fun Banner(
/**
* The in-app update banner (design-spec §3.6, S-17): Available (Lantern, "Uppdatera" disabled offline with "Kräver
* anslutning" as body), Downloading (progress, no actions), Failed (Error, "Försök igen"). NeedsInstallPermission looks
* anslutning" as body), Downloading (progress, no actions), Failed (Error, "Försök igen"; the title and body follow the
* reason, D-155: download, blocked by the phone's security settings, or another install failure with the installer's
* own message). NeedsInstallPermission looks
* like Available. Nothing for [UpdateState.None].
*/
@Composable
@@ -226,9 +229,16 @@ fun UpdateBanner(state: UpdateState, online: Boolean, onUpdate: () -> Unit, onLa
},
)
}
is UpdateState.Failed -> Banner(BannerKind.Error, KvittIcon.Error, stringResource(R.string.update_failed), modifier) { content ->
KvittButton(stringResource(R.string.update_later), onLater, kind = ButtonKind.Text, contentColor = content)
KvittButton(stringResource(R.string.common_retry), onRetry, kind = ButtonKind.ErrorFilled, icon = KvittIcon.Refresh)
is UpdateState.Failed -> {
val (title, body) = when (val reason = state.reason) {
UpdateFailure.Download -> stringResource(R.string.update_failed) to null
UpdateFailure.Blocked -> stringResource(R.string.update_install_failed) to stringResource(R.string.update_install_blocked)
is UpdateFailure.Install -> stringResource(R.string.update_install_failed) to reason.detail
}
Banner(BannerKind.Error, KvittIcon.Error, title, modifier, body = body) { content ->
KvittButton(stringResource(R.string.update_later), onLater, kind = ButtonKind.Text, contentColor = content)
KvittButton(stringResource(R.string.common_retry), onRetry, kind = ButtonKind.ErrorFilled, icon = KvittIcon.Refresh)
}
}
}
}
@@ -328,6 +328,10 @@
<string name="update_downloading_spoken">Laddar ner version %1$s</string>
<!-- AC-16.4 -->
<string name="update_failed">Nedladdningen misslyckades. Försök igen.</string>
<!-- D-155: the installer refused or failed; the banner's body is the reason -->
<string name="update_install_failed">Kunde inte installera uppdateringen</string>
<!-- D-155: STATUS_FAILURE_BLOCKED (device policy or package verifier); Android's own setting names per locale -->
<string name="update_install_blocked">Installationen blockerades av telefonens säkerhetsinställningar (Auto Blocker eller Installera okända appar). Ändra inställningen och försök igen.</string>
<string name="update_permission_title">Tillåt installation</string>
<!-- AC-16.3; bold the two Android setting names with <annotation>; Android's own labels per locale -->
<string name="update_permission_body">Kvitt uppdateras direkt från er server, inte från Play Butik. Därför frågar Android en gång om Kvitt får <b>Installera okända appar</b>. Slå på <b>Tillåt från den här källan</b> och gå sedan tillbaka till Kvitt. Uppdateringen fortsätter av sig själv.</string>
@@ -330,6 +330,10 @@
<string name="update_downloading_spoken">Downloading version %1$s</string>
<!-- AC-16.4 -->
<string name="update_failed">Download failed, try again</string>
<!-- D-155: the installer refused or failed; the banner's body is the reason -->
<string name="update_install_failed">Could not install the update</string>
<!-- D-155: STATUS_FAILURE_BLOCKED (device policy or package verifier); Android's own setting names per locale -->
<string name="update_install_blocked">The phone\'s security settings blocked the installation (Auto Blocker or Install unknown apps). Change the setting and try again.</string>
<string name="update_permission_title">Allow installation</string>
<!-- AC-16.3; bold the two Android setting names with <annotation>; Android's own labels per locale -->
<string name="update_permission_body">Kvitt updates straight from your own server, not from the Play Store, so Android asks once whether Kvitt may <b>Install unknown apps</b>. Turn on <b>Allow from this source</b> and come back to Kvitt. The update continues by itself.</string>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 51 KiB

@@ -18,6 +18,7 @@ import se.hagstedt.kvitt.model.SettlementId
import se.hagstedt.kvitt.model.Share
import se.hagstedt.kvitt.model.SplitMode
import se.hagstedt.kvitt.model.SyncRejectionReason
import se.hagstedt.kvitt.model.UpdateFailure
import se.hagstedt.kvitt.model.UpdateInfo
import se.hagstedt.kvitt.model.UpdateState
import se.hagstedt.kvitt.model.UserId
@@ -178,6 +179,9 @@ object HomeScenarios {
val update = UpdateInfo(versionCode = 2, versionName = "1.2", sizeBytes = 8_400_000)
/** A real `EXTRA_STATUS_MESSAGE` of a signature mismatch (an APK signed with another key). */
const val INSTALLER_MESSAGE = "INSTALL_FAILED_UPDATE_INCOMPATIBLE: Existing package se.hagstedt.kvitt signatures do not match newer version; ignoring!"
/** [household] with version 1.2 available. */
val updateAvailable: FakeScenario = household.copy(update = UpdateState.Available(update))
@@ -190,6 +194,12 @@ object HomeScenarios {
/** [household] after a failed download. */
val updateFailed: FakeScenario = household.copy(update = UpdateState.Failed(update))
/** [household] after the phone's security settings blocked the install (D-155). */
val updateBlocked: FakeScenario = household.copy(update = UpdateState.Failed(update, UpdateFailure.Blocked))
/** [household] after another install failure, with the installer's own message (D-155). */
val updateInstallFailed: FakeScenario = household.copy(update = UpdateState.Failed(update, UpdateFailure.Install(INSTALLER_MESSAGE)))
/** Offline with two changes waiting and version 1.2 available (S-4-home-offline). */
val offline: FakeScenario = pending.copy(online = false, update = UpdateState.Available(update))
@@ -23,6 +23,7 @@ import se.hagstedt.kvitt.model.SessionState
import se.hagstedt.kvitt.model.Share
import se.hagstedt.kvitt.model.SplitMode
import se.hagstedt.kvitt.model.TemplateId
import se.hagstedt.kvitt.model.UpdateFailure
import se.hagstedt.kvitt.model.UpdateInfo
import se.hagstedt.kvitt.model.UpdateState
import se.hagstedt.kvitt.model.User
@@ -158,6 +159,9 @@ object SettingsScenarios {
/** The update is downloading (S-22). */
val updateDownloading: FakeScenario = loaded.copy(update = UpdateState.Downloading(update, 0.64f))
/** The phone's security settings blocked the install (S-22, D-155). */
val updateBlocked: FakeScenario = loaded.copy(update = UpdateState.Failed(update, UpdateFailure.Blocked))
private val longMe = jacob.copy(displayName = "Jacob Hagstedt-Lindqvist")
private val longPartner = anna.copy(displayName = "Anna-Karin Lindqvist-Östergren")
private val longAccount = huset.copy(name = "Sommarstugan på Gotland med brygga")
@@ -34,10 +34,28 @@ sealed interface UpdateState {
data object None : UpdateState
data class Available(val info: UpdateInfo) : UpdateState
data class Downloading(val info: UpdateInfo, val progress: Float) : UpdateState
data class Failed(val info: UpdateInfo) : UpdateState
/** The download or the install failed (AC-16.4, D-155); [reason] picks the banner's text, "Försök igen" downloads again. */
data class Failed(val info: UpdateInfo, val reason: UpdateFailure = UpdateFailure.Download) : UpdateState
data class NeedsInstallPermission(val info: UpdateInfo) : UpdateState
}
/** Why [UpdateState.Failed] (D-155). The downloaded file is deleted in every case. */
sealed interface UpdateFailure {
/** No answer, an error, or the size or SHA-256 did not match (AC-16.4). */
data object Download : UpdateFailure
/** The installer refused the install as blocked (`STATUS_FAILURE_BLOCKED`): the phone's security settings. */
data object Blocked : UpdateFailure
/**
* Any other installer failure (invalid, conflict, storage, incompatible, timeout, generic). [detail] is the
* installer's own status message, null when it gave none; it names packages and error codes, never personal data.
*/
data class Install(val detail: String?) : UpdateFailure
}
/** The outcome of [UpdateRepository.check] and [UpdateRepository.checkNow] (S-22, AC-16.6). */
sealed interface UpdateCheck {
/** No newer APK is published (or nothing is published); debug builds, which never check (AC-16.5), report this too. */
@@ -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) }
}
@@ -53,6 +53,8 @@ val homeStates: List<ScreenState> = listOf(
home("S-17-update", HomeScenarios.updateNeedsPermission) { it.onUpdate() },
home("S-17-update-progress", HomeScenarios.updateDownloading),
home("S-17-update-failed", HomeScenarios.updateFailed),
home("S-17-install-blocked", HomeScenarios.updateBlocked),
home("S-17-install-failed", HomeScenarios.updateInstallFailed),
sync("S-16-sync", HomeScenarios.syncLoaded),
sync("S-16-sync-empty", HomeScenarios.household),
sync("S-16-sync-rejections", HomeScenarios.allRejections),
@@ -107,6 +109,7 @@ class HomeScreenshotTest(private val state: String) {
"S-4-home-syncing", "S-4-home-empty", "S-4-home-loading", "S-4-home-offline", "S-4-home-only-archived", "S-4-home-long",
"S-4-home-refreshing",
"S-17-update-banner", "S-17-update-permission", "S-17-update", "S-17-update-progress", "S-17-update-failed",
"S-17-install-blocked", "S-17-install-failed",
"S-16-sync", "S-16-sync-empty", "S-16-sync-rejections", "S-16-sync-clock", "S-16-sync-running", "S-16-sync-error",
"S-16-sync-offline", "S-16-sync-long", "S-16-sync-lost", "S-16-sync-lost-payment", "S-16-sync-lost-deleted",
).map { arrayOf<Any>(it) }
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

@@ -118,6 +118,7 @@ class DevicesInvitesAboutScreenshotTest(private val state: String) {
},
about("downloading", SettingsScenarios.updateDownloading),
about("update_failed", SettingsScenarios.loaded.copy(update = UpdateState.Failed(SettingsScenarios.update))),
about("install_blocked", SettingsScenarios.updateBlocked),
)
@JvmStatic
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+2
View File
@@ -220,6 +220,8 @@ update_later,Later,Senare,module=core:design
update_downloading,Downloading… %1$d %%,Laddar ner… %1$d %%,module=core:design
update_downloading_spoken,Downloading version %1$s,Laddar ner version %1$s,module=core:design; TalkBack of the progress bar
update_failed,"Download failed, try again",Nedladdningen misslyckades. Försök igen.,module=core:design; AC-16.4
update_install_failed,Could not install the update,Kunde inte installera uppdateringen,module=core:design; D-155 banner title after an install failure
update_install_blocked,The phone's security settings blocked the installation (Auto Blocker or Install unknown apps). Change the setting and try again.,Installationen blockerades av telefonens säkerhetsinställningar (Auto Blocker eller Installera okända appar). Ändra inställningen och försök igen.,module=core:design; D-155 STATUS_FAILURE_BLOCKED body
update_permission_title,Allow installation,Tillåt installation,module=core:design
update_permission_body,"Kvitt updates straight from your own server, not from the Play Store, so Android asks once whether Kvitt may Install unknown apps. Turn on Allow from this source and come back to Kvitt. The update continues by itself.","Kvitt uppdateras direkt från er server, inte från Play Butik. Därför frågar Android en gång om Kvitt får Installera okända appar. Slå på Tillåt från den här källan och gå sedan tillbaka till Kvitt. Uppdateringen fortsätter av sig själv.",module=core:design; AC-16.3; bold the two Android setting names with <annotation>; Android's own labels per locale
error_household_full,Your partner has already joined,Din partner har redan gått med,module=core:design; household_full
Can't render this file because it contains an unexpected character in line 156 and column 79.