diff --git a/android/core/design/src/debug/kotlin/se/hagstedt/kvitt/design/catalogue/Catalogue.kt b/android/core/design/src/debug/kotlin/se/hagstedt/kvitt/design/catalogue/Catalogue.kt index ca17bae..d95cb2a 100644 --- a/android/core/design/src/debug/kotlin/se/hagstedt/kvitt/design/catalogue/Catalogue.kt +++ b/android/core/design/src/debug/kotlin/se/hagstedt/kvitt/design/catalogue/Catalogue.kt @@ -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 diff --git a/android/core/design/src/debug/kotlin/se/hagstedt/kvitt/design/catalogue/Samples.kt b/android/core/design/src/debug/kotlin/se/hagstedt/kvitt/design/catalogue/Samples.kt index a276526..78eea70 100644 --- a/android/core/design/src/debug/kotlin/se/hagstedt/kvitt/design/catalogue/Samples.kt +++ b/android/core/design/src/debug/kotlin/se/hagstedt/kvitt/design/catalogue/Samples.kt @@ -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)) diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Sync.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Sync.kt index 333b8d9..fba90f7 100644 --- a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Sync.kt +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Sync.kt @@ -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) + } } } } diff --git a/android/core/design/src/main/res/values-sv/strings.xml b/android/core/design/src/main/res/values-sv/strings.xml index fd3ce32..6385aac 100644 --- a/android/core/design/src/main/res/values-sv/strings.xml +++ b/android/core/design/src/main/res/values-sv/strings.xml @@ -328,6 +328,10 @@ Laddar ner version %1$s Nedladdningen misslyckades. Försök igen. + + Kunde inte installera uppdateringen + + Installationen blockerades av telefonens säkerhetsinställningar (Auto Blocker eller Installera okända appar). Ändra inställningen och försök igen. Tillåt installation 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. diff --git a/android/core/design/src/main/res/values/strings.xml b/android/core/design/src/main/res/values/strings.xml index 8a47177..dc933c9 100644 --- a/android/core/design/src/main/res/values/strings.xml +++ b/android/core/design/src/main/res/values/strings.xml @@ -330,6 +330,10 @@ Downloading version %1$s Download failed, try again + + Could not install the update + + The phone\'s security settings blocked the installation (Auto Blocker or Install unknown apps). Change the setting and try again. Allow installation 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. diff --git a/android/core/design/src/test/screenshots/update_banner_en_light.png b/android/core/design/src/test/screenshots/update_banner_en_light.png index 4f6b157..058d856 100644 Binary files a/android/core/design/src/test/screenshots/update_banner_en_light.png and b/android/core/design/src/test/screenshots/update_banner_en_light.png differ diff --git a/android/core/design/src/test/screenshots/update_banner_sv_dark.png b/android/core/design/src/test/screenshots/update_banner_sv_dark.png index be13475..98449fa 100644 Binary files a/android/core/design/src/test/screenshots/update_banner_sv_dark.png and b/android/core/design/src/test/screenshots/update_banner_sv_dark.png differ diff --git a/android/core/design/src/test/screenshots/update_banner_sv_light.png b/android/core/design/src/test/screenshots/update_banner_sv_light.png index 171a238..6c8f2d0 100644 Binary files a/android/core/design/src/test/screenshots/update_banner_sv_light.png and b/android/core/design/src/test/screenshots/update_banner_sv_light.png differ diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/home/HomeScenarios.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/home/HomeScenarios.kt index 7cf821f..be52790 100644 --- a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/home/HomeScenarios.kt +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/home/HomeScenarios.kt @@ -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)) diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/settings/SettingsScenarios.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/settings/SettingsScenarios.kt index cd6904d..dd7baf7 100644 --- a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/settings/SettingsScenarios.kt +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/settings/SettingsScenarios.kt @@ -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") diff --git a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Sync.kt b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Sync.kt index 5dbe7e6..64dbad0 100644 --- a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Sync.kt +++ b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Sync.kt @@ -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. */ diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/update/Updater.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/update/Updater.kt index d057e33..65e0024 100644 --- a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/update/Updater.kt +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/update/Updater.kt @@ -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>() + + fun expect(sessionId: Int): CompletableDeferred = CompletableDeferred().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" } } diff --git a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/update/UpdaterTest.kt b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/update/UpdaterTest.kt index aab033d..69d4e32 100644 --- a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/update/UpdaterTest.kt +++ b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/update/UpdaterTest.kt @@ -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() + val bytes = mutableListOf() + + /** 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) } } diff --git a/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeScreenshotTest.kt b/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeScreenshotTest.kt index c1efa49..05b8b8f 100644 --- a/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeScreenshotTest.kt +++ b/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeScreenshotTest.kt @@ -53,6 +53,8 @@ val homeStates: List = 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(it) } diff --git a/android/feature/home/src/test/screenshots/S-17-install-blocked_en_light.png b/android/feature/home/src/test/screenshots/S-17-install-blocked_en_light.png new file mode 100644 index 0000000..57afe8e Binary files /dev/null and b/android/feature/home/src/test/screenshots/S-17-install-blocked_en_light.png differ diff --git a/android/feature/home/src/test/screenshots/S-17-install-blocked_sv_dark.png b/android/feature/home/src/test/screenshots/S-17-install-blocked_sv_dark.png new file mode 100644 index 0000000..eea34f8 Binary files /dev/null and b/android/feature/home/src/test/screenshots/S-17-install-blocked_sv_dark.png differ diff --git a/android/feature/home/src/test/screenshots/S-17-install-blocked_sv_light.png b/android/feature/home/src/test/screenshots/S-17-install-blocked_sv_light.png new file mode 100644 index 0000000..75e183d Binary files /dev/null and b/android/feature/home/src/test/screenshots/S-17-install-blocked_sv_light.png differ diff --git a/android/feature/home/src/test/screenshots/S-17-install-failed_en_light.png b/android/feature/home/src/test/screenshots/S-17-install-failed_en_light.png new file mode 100644 index 0000000..536bc75 Binary files /dev/null and b/android/feature/home/src/test/screenshots/S-17-install-failed_en_light.png differ diff --git a/android/feature/home/src/test/screenshots/S-17-install-failed_sv_dark.png b/android/feature/home/src/test/screenshots/S-17-install-failed_sv_dark.png new file mode 100644 index 0000000..4016303 Binary files /dev/null and b/android/feature/home/src/test/screenshots/S-17-install-failed_sv_dark.png differ diff --git a/android/feature/home/src/test/screenshots/S-17-install-failed_sv_light.png b/android/feature/home/src/test/screenshots/S-17-install-failed_sv_light.png new file mode 100644 index 0000000..4ddb691 Binary files /dev/null and b/android/feature/home/src/test/screenshots/S-17-install-failed_sv_light.png differ diff --git a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/DevicesInvitesAboutScreenshotTest.kt b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/DevicesInvitesAboutScreenshotTest.kt index b2d60e5..b205333 100644 --- a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/DevicesInvitesAboutScreenshotTest.kt +++ b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/DevicesInvitesAboutScreenshotTest.kt @@ -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 diff --git a/android/feature/settings/src/test/screenshots/s22_about_install_blocked_en_light.png b/android/feature/settings/src/test/screenshots/s22_about_install_blocked_en_light.png new file mode 100644 index 0000000..7f479f9 Binary files /dev/null and b/android/feature/settings/src/test/screenshots/s22_about_install_blocked_en_light.png differ diff --git a/android/feature/settings/src/test/screenshots/s22_about_install_blocked_sv_dark.png b/android/feature/settings/src/test/screenshots/s22_about_install_blocked_sv_dark.png new file mode 100644 index 0000000..77d0d1d Binary files /dev/null and b/android/feature/settings/src/test/screenshots/s22_about_install_blocked_sv_dark.png differ diff --git a/android/feature/settings/src/test/screenshots/s22_about_install_blocked_sv_light.png b/android/feature/settings/src/test/screenshots/s22_about_install_blocked_sv_light.png new file mode 100644 index 0000000..ccf1c6f Binary files /dev/null and b/android/feature/settings/src/test/screenshots/s22_about_install_blocked_sv_light.png differ diff --git a/docs/ux/strings.csv b/docs/ux/strings.csv index a17bfff..d693235 100644 --- a/docs/ux/strings.csv +++ b/docs/ux/strings.csv @@ -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 ; 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