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.ExpenseEffect
|
||||||
import se.hagstedt.kvitt.model.Share
|
import se.hagstedt.kvitt.model.Share
|
||||||
import se.hagstedt.kvitt.model.SplitMode
|
import se.hagstedt.kvitt.model.SplitMode
|
||||||
|
import se.hagstedt.kvitt.model.UpdateFailure
|
||||||
import se.hagstedt.kvitt.model.UpdateState
|
import se.hagstedt.kvitt.model.UpdateState
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -300,6 +301,8 @@ fun UpdateBannerSheet() {
|
|||||||
UpdateBanner(UpdateState.Available(Samples.update), online = false, {}, {}, {})
|
UpdateBanner(UpdateState.Available(Samples.update), online = false, {}, {}, {})
|
||||||
UpdateBanner(UpdateState.Downloading(Samples.update, 0.64f), online = true, {}, {}, {})
|
UpdateBanner(UpdateState.Downloading(Samples.update, 0.64f), online = true, {}, {}, {})
|
||||||
UpdateBanner(UpdateState.Failed(Samples.update), 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
|
@Composable
|
||||||
|
|||||||
@@ -102,6 +102,9 @@ object Samples {
|
|||||||
|
|
||||||
val update = UpdateInfo(versionCode = 12, versionName = "1.2", sizeBytes = 8_400_000)
|
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 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))
|
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.icon.KvittIcon
|
||||||
import se.hagstedt.kvitt.design.theme.KvittDimens
|
import se.hagstedt.kvitt.design.theme.KvittDimens
|
||||||
import se.hagstedt.kvitt.model.SyncSummary
|
import se.hagstedt.kvitt.model.SyncSummary
|
||||||
|
import se.hagstedt.kvitt.model.UpdateFailure
|
||||||
import se.hagstedt.kvitt.model.UpdateState
|
import se.hagstedt.kvitt.model.UpdateState
|
||||||
import kotlin.time.Instant
|
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
|
* 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].
|
* like Available. Nothing for [UpdateState.None].
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
@@ -226,11 +229,18 @@ fun UpdateBanner(state: UpdateState, online: Boolean, onUpdate: () -> Unit, onLa
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is UpdateState.Failed -> Banner(BannerKind.Error, KvittIcon.Error, stringResource(R.string.update_failed), modifier) { content ->
|
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.update_later), onLater, kind = ButtonKind.Text, contentColor = content)
|
||||||
KvittButton(stringResource(R.string.common_retry), onRetry, kind = ButtonKind.ErrorFilled, icon = KvittIcon.Refresh)
|
KvittButton(stringResource(R.string.common_retry), onRetry, kind = ButtonKind.ErrorFilled, icon = KvittIcon.Refresh)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val TRACK_ALPHA = 0.28f
|
private const val TRACK_ALPHA = 0.28f
|
||||||
|
|||||||
@@ -328,6 +328,10 @@
|
|||||||
<string name="update_downloading_spoken">Laddar ner version %1$s</string>
|
<string name="update_downloading_spoken">Laddar ner version %1$s</string>
|
||||||
<!-- AC-16.4 -->
|
<!-- AC-16.4 -->
|
||||||
<string name="update_failed">Nedladdningen misslyckades. Försök igen.</string>
|
<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>
|
<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 -->
|
<!-- 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>
|
<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>
|
<string name="update_downloading_spoken">Downloading version %1$s</string>
|
||||||
<!-- AC-16.4 -->
|
<!-- AC-16.4 -->
|
||||||
<string name="update_failed">Download failed, try again</string>
|
<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>
|
<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 -->
|
<!-- 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>
|
<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.Share
|
||||||
import se.hagstedt.kvitt.model.SplitMode
|
import se.hagstedt.kvitt.model.SplitMode
|
||||||
import se.hagstedt.kvitt.model.SyncRejectionReason
|
import se.hagstedt.kvitt.model.SyncRejectionReason
|
||||||
|
import se.hagstedt.kvitt.model.UpdateFailure
|
||||||
import se.hagstedt.kvitt.model.UpdateInfo
|
import se.hagstedt.kvitt.model.UpdateInfo
|
||||||
import se.hagstedt.kvitt.model.UpdateState
|
import se.hagstedt.kvitt.model.UpdateState
|
||||||
import se.hagstedt.kvitt.model.UserId
|
import se.hagstedt.kvitt.model.UserId
|
||||||
@@ -178,6 +179,9 @@ object HomeScenarios {
|
|||||||
|
|
||||||
val update = UpdateInfo(versionCode = 2, versionName = "1.2", sizeBytes = 8_400_000)
|
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. */
|
/** [household] with version 1.2 available. */
|
||||||
val updateAvailable: FakeScenario = household.copy(update = UpdateState.Available(update))
|
val updateAvailable: FakeScenario = household.copy(update = UpdateState.Available(update))
|
||||||
|
|
||||||
@@ -190,6 +194,12 @@ object HomeScenarios {
|
|||||||
/** [household] after a failed download. */
|
/** [household] after a failed download. */
|
||||||
val updateFailed: FakeScenario = household.copy(update = UpdateState.Failed(update))
|
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). */
|
/** 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))
|
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.Share
|
||||||
import se.hagstedt.kvitt.model.SplitMode
|
import se.hagstedt.kvitt.model.SplitMode
|
||||||
import se.hagstedt.kvitt.model.TemplateId
|
import se.hagstedt.kvitt.model.TemplateId
|
||||||
|
import se.hagstedt.kvitt.model.UpdateFailure
|
||||||
import se.hagstedt.kvitt.model.UpdateInfo
|
import se.hagstedt.kvitt.model.UpdateInfo
|
||||||
import se.hagstedt.kvitt.model.UpdateState
|
import se.hagstedt.kvitt.model.UpdateState
|
||||||
import se.hagstedt.kvitt.model.User
|
import se.hagstedt.kvitt.model.User
|
||||||
@@ -158,6 +159,9 @@ object SettingsScenarios {
|
|||||||
/** The update is downloading (S-22). */
|
/** The update is downloading (S-22). */
|
||||||
val updateDownloading: FakeScenario = loaded.copy(update = UpdateState.Downloading(update, 0.64f))
|
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 longMe = jacob.copy(displayName = "Jacob Hagstedt-Lindqvist")
|
||||||
private val longPartner = anna.copy(displayName = "Anna-Karin Lindqvist-Östergren")
|
private val longPartner = anna.copy(displayName = "Anna-Karin Lindqvist-Östergren")
|
||||||
private val longAccount = huset.copy(name = "Sommarstugan på Gotland med brygga")
|
private val longAccount = huset.copy(name = "Sommarstugan på Gotland med brygga")
|
||||||
|
|||||||
@@ -34,10 +34,28 @@ sealed interface UpdateState {
|
|||||||
data object None : UpdateState
|
data object None : UpdateState
|
||||||
data class Available(val info: UpdateInfo) : UpdateState
|
data class Available(val info: UpdateInfo) : UpdateState
|
||||||
data class Downloading(val info: UpdateInfo, val progress: Float) : 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
|
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). */
|
/** The outcome of [UpdateRepository.check] and [UpdateRepository.checkNow] (S-22, AC-16.6). */
|
||||||
sealed interface UpdateCheck {
|
sealed interface UpdateCheck {
|
||||||
/** No newer APK is published (or nothing is published); debug builds, which never check (AC-16.5), report this too. */
|
/** 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.ByteReadChannel
|
||||||
import io.ktor.utils.io.readAvailable
|
import io.ktor.utils.io.readAvailable
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
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.repo.PhonePrefs
|
||||||
import se.hagstedt.kvitt.data.util.Clock
|
import se.hagstedt.kvitt.data.util.Clock
|
||||||
import se.hagstedt.kvitt.model.UpdateCheck
|
import se.hagstedt.kvitt.model.UpdateCheck
|
||||||
|
import se.hagstedt.kvitt.model.UpdateFailure
|
||||||
import se.hagstedt.kvitt.model.UpdateInfo
|
import se.hagstedt.kvitt.model.UpdateInfo
|
||||||
import se.hagstedt.kvitt.model.UpdateRepository
|
import se.hagstedt.kvitt.model.UpdateRepository
|
||||||
import se.hagstedt.kvitt.model.UpdateState
|
import se.hagstedt.kvitt.model.UpdateState
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
/** The `PackageInstaller` call, isolated so the updater's logic is unit-tested without a device. */
|
/** The `PackageInstaller` call, isolated so the updater's logic is unit-tested without a device. */
|
||||||
interface ApkInstaller {
|
interface ApkInstaller {
|
||||||
/** Whether "Install unknown apps" is allowed for Kvitt. */
|
/** Whether "Install unknown apps" is allowed for Kvitt. */
|
||||||
fun canRequestPackageInstalls(): Boolean
|
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). */
|
/** 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
|
* 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
|
* [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
|
* 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(
|
class UpdateRepositoryImpl(
|
||||||
private val api: KvittApi,
|
private val api: KvittApi,
|
||||||
@@ -148,8 +208,33 @@ class UpdateRepositoryImpl(
|
|||||||
mutable.value = UpdateState.Failed(info)
|
mutable.value = UpdateState.Failed(info)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
val pending = try {
|
||||||
installer.install(file)
|
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)
|
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) {
|
private suspend fun copy(channel: ByteReadChannel, file: File, expected: Long, info: UpdateInfo) = withContext(Dispatchers.IO) {
|
||||||
@@ -186,9 +271,12 @@ class UpdateRepositoryImpl(
|
|||||||
class AndroidApkInstaller(private val context: Context) : ApkInstaller {
|
class AndroidApkInstaller(private val context: Context) : ApkInstaller {
|
||||||
override fun canRequestPackageInstalls(): Boolean = context.packageManager.canRequestPackageInstalls()
|
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 installer = context.packageManager.packageInstaller
|
||||||
val sessionId = installer.createSession(PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL))
|
val sessionId = installer.createSession(PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL))
|
||||||
|
// Registered before the commit: the answer can arrive before this call returns.
|
||||||
|
val answer = InstallResults.expect(sessionId)
|
||||||
|
try {
|
||||||
installer.openSession(sessionId).use { session ->
|
installer.openSession(sessionId).use { session ->
|
||||||
apk.inputStream().use { input ->
|
apk.inputStream().use { input ->
|
||||||
session.openWrite("kvitt.apk", 0, apk.length()).use { output ->
|
session.openWrite("kvitt.apk", 0, apk.length()).use { output ->
|
||||||
@@ -201,18 +289,46 @@ class AndroidApkInstaller(private val context: Context) : ApkInstaller {
|
|||||||
val pending = PendingIntent.getBroadcast(context, sessionId, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE)
|
val pending = PendingIntent.getBroadcast(context, sessionId, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE)
|
||||||
session.commit(pending.intentSender)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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() {
|
class InstallResultReceiver : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
when (val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) {
|
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 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))
|
context.startActivity(it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||||
}
|
}
|
||||||
PackageInstaller.STATUS_SUCCESS -> Unit
|
return
|
||||||
else -> Log.w("KvittUpdate", "install finished with status $status")
|
|
||||||
}
|
}
|
||||||
|
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
|
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 io.ktor.http.HttpStatusCode
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
@@ -10,12 +13,14 @@ import org.junit.Before
|
|||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.RuntimeEnvironment
|
||||||
import org.robolectric.annotation.Config
|
import org.robolectric.annotation.Config
|
||||||
import se.hagstedt.kvitt.data.Harness
|
import se.hagstedt.kvitt.data.Harness
|
||||||
import se.hagstedt.kvitt.data.api.ApkLatestDto
|
import se.hagstedt.kvitt.data.api.ApkLatestDto
|
||||||
import se.hagstedt.kvitt.data.api.KvittJson
|
import se.hagstedt.kvitt.data.api.KvittJson
|
||||||
import se.hagstedt.kvitt.data.runSync
|
import se.hagstedt.kvitt.data.runSync
|
||||||
import se.hagstedt.kvitt.model.UpdateCheck
|
import se.hagstedt.kvitt.model.UpdateCheck
|
||||||
|
import se.hagstedt.kvitt.model.UpdateFailure
|
||||||
import se.hagstedt.kvitt.model.UpdateInfo
|
import se.hagstedt.kvitt.model.UpdateInfo
|
||||||
import se.hagstedt.kvitt.model.UpdateState
|
import se.hagstedt.kvitt.model.UpdateState
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -31,15 +36,27 @@ class UpdaterTest {
|
|||||||
private val installer = FakeInstaller()
|
private val installer = FakeInstaller()
|
||||||
private val apk = ByteArray(200_000) { (it % 251).toByte() }
|
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 {
|
class FakeInstaller : ApkInstaller {
|
||||||
var allowed = true
|
var allowed = true
|
||||||
|
var outcome: InstallOutcome = InstallOutcome.Success
|
||||||
val installed = mutableListOf<File>()
|
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 fun canRequestPackageInstalls() = allowed
|
||||||
|
|
||||||
override suspend fun install(apk: File) {
|
override suspend fun install(apk: File): PendingInstall {
|
||||||
assertTrue(apk.exists())
|
assertTrue(apk.exists())
|
||||||
installed += apk
|
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 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
|
@Test
|
||||||
fun newerVersionIsAvailable_sameIsNone() = runSync {
|
fun newerVersionIsAvailable_sameIsNone() = runSync {
|
||||||
@@ -162,9 +194,94 @@ class UpdaterTest {
|
|||||||
repo.check()
|
repo.check()
|
||||||
repo.install()
|
repo.install()
|
||||||
assertEquals(apkFile(Harness.APP_VERSION + 1), installer.installed.single())
|
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
|
@Test
|
||||||
fun shaMismatchDeletesTheFile_andFails() = runSync {
|
fun shaMismatchDeletesTheFile_andFails() = runSync {
|
||||||
serve(latest(Harness.APP_VERSION + 1, sha = "0".repeat(64)))
|
serve(latest(Harness.APP_VERSION + 1, sha = "0".repeat(64)))
|
||||||
@@ -199,5 +316,10 @@ class UpdaterTest {
|
|||||||
assertEquals(1, installer.installed.size)
|
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) }
|
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", HomeScenarios.updateNeedsPermission) { it.onUpdate() },
|
||||||
home("S-17-update-progress", HomeScenarios.updateDownloading),
|
home("S-17-update-progress", HomeScenarios.updateDownloading),
|
||||||
home("S-17-update-failed", HomeScenarios.updateFailed),
|
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", HomeScenarios.syncLoaded),
|
||||||
sync("S-16-sync-empty", HomeScenarios.household),
|
sync("S-16-sync-empty", HomeScenarios.household),
|
||||||
sync("S-16-sync-rejections", HomeScenarios.allRejections),
|
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-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-4-home-refreshing",
|
||||||
"S-17-update-banner", "S-17-update-permission", "S-17-update", "S-17-update-progress", "S-17-update-failed",
|
"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", "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",
|
"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) }
|
).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("downloading", SettingsScenarios.updateDownloading),
|
||||||
about("update_failed", SettingsScenarios.loaded.copy(update = UpdateState.Failed(SettingsScenarios.update))),
|
about("update_failed", SettingsScenarios.loaded.copy(update = UpdateState.Failed(SettingsScenarios.update))),
|
||||||
|
about("install_blocked", SettingsScenarios.updateBlocked),
|
||||||
)
|
)
|
||||||
|
|
||||||
@JvmStatic
|
@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,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_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_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_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
|
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
|
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.
|