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
@@ -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>
|
||||
|
||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 51 KiB |
|
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) }
|
||||
|
||||
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
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
|
||||
|
||||
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 35 KiB |
@@ -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.
|