Integrate persistent updater metadata and failure operations
This commit is contained in:
commit
9bbb4bab25
15 changed files with 559 additions and 99 deletions
|
|
@ -38,6 +38,7 @@ class HouseholdUpdateControllerTest {
|
|||
private var truncated = false
|
||||
private var chunked = false
|
||||
private var unsupported = false
|
||||
private var manifestSigner = UpdateChannel.SIGNER
|
||||
private var redirect = false
|
||||
private var redirectTarget: String? = null
|
||||
private val requests = mutableListOf<Map<String, List<String>>>()
|
||||
|
|
@ -49,12 +50,14 @@ class HouseholdUpdateControllerTest {
|
|||
var permission = true
|
||||
var settingsAvailable = true
|
||||
var installs = 0
|
||||
var settingsOpens = 0
|
||||
var inspectAction: (() -> Unit)? = null
|
||||
var outcome = InstallOutcome.Cancelled
|
||||
override fun installed() = installed
|
||||
override fun inspect(file: File) = archive
|
||||
override fun inspect(file: File): ApkIdentity { inspectAction?.invoke(); return archive }
|
||||
override fun hasInstallPermission() = permission
|
||||
override fun openPermissionSettings() = settingsAvailable
|
||||
override fun install(file: File, outcome: (InstallOutcome) -> Unit) { installs++; outcome(this.outcome) }
|
||||
override fun openPermissionSettings(): Boolean { settingsOpens++; return settingsAvailable }
|
||||
override fun install(file: File, asset: UpdateAsset, outcome: (InstallOutcome) -> Unit) { installs++; outcome(this.outcome) }
|
||||
}
|
||||
private val preferences = object : UpdatePreferences {
|
||||
override val autoCheck = MutableStateFlow(true)
|
||||
|
|
@ -104,7 +107,7 @@ class HouseholdUpdateControllerTest {
|
|||
@After fun stop() { server.stop(0) }
|
||||
|
||||
private fun manifest() = UpdateManifest(1, UpdateChannel.PACKAGE, "1.1.1", code,
|
||||
"2026-11-07T12:00:00Z", "a".repeat(40), "pwolfskin-opt-v1.1.1", UpdateChannel.SIGNER,
|
||||
"2026-11-07T12:00:00Z", "a".repeat(40), "pwolfskin-opt-v1.1.1", manifestSigner,
|
||||
listOf(UpdateAsset(if (unsupported) "x86_64" else "armeabi-v7a", if (unsupported) "Wolfskin-x86_64.apk" else "Wolfskin-armeabi-v7a.apk", apk.size.toLong(),
|
||||
if (badHash) "0".repeat(64) else MessageDigest.getInstance("SHA-256").digest(apk).joinToString("") { "%02x".format(it) }, 23)))
|
||||
|
||||
|
|
@ -115,17 +118,32 @@ class HouseholdUpdateControllerTest {
|
|||
val controller = controller(this)
|
||||
controller.check(true); advanceUntilIdle()
|
||||
assertTrue(controller.state.value is UpdateUiState.Available)
|
||||
val metadata = controller.release.value
|
||||
assertEquals(controller.state.value, metadata)
|
||||
controller.download(); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
|
||||
assertEquals(metadata, controller.release.value)
|
||||
assertEquals(0, platform.installs)
|
||||
val restored = controller(this); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.Ready("1.1.1"), restored.state.value)
|
||||
assertEquals(metadata, restored.release.value)
|
||||
restored.install(); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.InstallCancelled, restored.state.value)
|
||||
assertEquals(1, platform.installs)
|
||||
assertTrue(File(directory, "verified.apk").exists())
|
||||
}
|
||||
|
||||
@Test fun existingManifestOnlyReadyRecordRemainsReadable() = runTest {
|
||||
val old = controller(this); old.check(true); advanceUntilIdle(); old.download(); advanceUntilIdle()
|
||||
val metadata = old.release.value!!
|
||||
assertEquals(manifest(), UpdateManifest.parse(File(directory, "ready.json").readText()))
|
||||
File(directory, "ready.json").writeText(UpdateManifest.encode(manifest()))
|
||||
val recreated = controller(this); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.Ready("1.1.1"), recreated.state.value)
|
||||
assertEquals(metadata.copy(notes = ""), recreated.release.value)
|
||||
assertEquals(0, platform.installs)
|
||||
}
|
||||
|
||||
@Test fun equalCodeIsUpToDateAndOlderCodeIsNotASuccess() = runTest {
|
||||
code = 65
|
||||
val equal = controller(this); equal.check(true); advanceUntilIdle()
|
||||
|
|
@ -163,6 +181,15 @@ class HouseholdUpdateControllerTest {
|
|||
assertEquals(UpdateFailure.UnsupportedAbi, (unsupported.state.value as UpdateUiState.Failed).kind)
|
||||
}
|
||||
|
||||
@Test fun manifestSignerDifferentFromInstallationFailsBeforeDownload() = runTest {
|
||||
manifestSigner = "b".repeat(64)
|
||||
val controller = controller(this)
|
||||
controller.check(true); advanceUntilIdle()
|
||||
assertEquals(UpdateFailure.WrongSigner, (controller.state.value as UpdateUiState.Failed).kind)
|
||||
assertFalse(File(directory, "verified.apk").exists())
|
||||
assertEquals(0, platform.installs)
|
||||
}
|
||||
|
||||
@Test fun checksumAndLengthFailuresNeverCommitReady() = runTest {
|
||||
for (badChecksum in listOf(true, false)) {
|
||||
badHash = badChecksum; truncated = !badChecksum
|
||||
|
|
@ -243,6 +270,53 @@ class HouseholdUpdateControllerTest {
|
|||
assertEquals(UpdateUiState.NoInstaller, controller.state.value)
|
||||
}
|
||||
|
||||
@Test fun permissionDismissalRetainsReadyAndInstallReopensSettings() = runTest {
|
||||
val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
|
||||
val metadata = controller.release.value
|
||||
platform.permission = false
|
||||
controller.install(); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.PermissionNeeded, controller.state.value)
|
||||
controller.install(); advanceUntilIdle()
|
||||
assertEquals(2, platform.settingsOpens)
|
||||
controller.cancel(); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
|
||||
assertEquals(metadata, controller.release.value)
|
||||
assertArrayEquals(apk, File(directory, "verified.apk").readBytes())
|
||||
controller.install(); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.PermissionNeeded, controller.state.value)
|
||||
assertEquals(3, platform.settingsOpens)
|
||||
assertEquals(0, platform.installs)
|
||||
}
|
||||
|
||||
@Test fun handoffDismissalKeepsReadyWithoutAllowingAnotherOwnedSession() = runTest {
|
||||
val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
|
||||
platform.outcome = InstallOutcome.Pending
|
||||
controller.install(); advanceUntilIdle()
|
||||
controller.cancel(); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
|
||||
controller.handleInstallOutcome(InstallOutcome.Pending); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
|
||||
val before = requests.size
|
||||
controller.install(); advanceUntilIdle()
|
||||
controller.check(true); advanceUntilIdle()
|
||||
controller.download(); advanceUntilIdle()
|
||||
assertEquals(1, platform.installs)
|
||||
assertEquals(before, requests.size)
|
||||
assertArrayEquals(apk, File(directory, "verified.apk").readBytes())
|
||||
controller.handleInstallOutcome(InstallOutcome.Cancelled); advanceUntilIdle()
|
||||
controller.install(); advanceUntilIdle()
|
||||
assertEquals(2, platform.installs)
|
||||
}
|
||||
|
||||
@Test fun dismissalDuringInstallVerificationDoesNotDiscardVerifiedBytes() = runTest {
|
||||
val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
|
||||
platform.inspectAction = { controller.cancel() }
|
||||
controller.install(); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
|
||||
assertArrayEquals(apk, File(directory, "verified.apk").readBytes())
|
||||
assertEquals(0, platform.installs)
|
||||
}
|
||||
|
||||
@Test fun corruptPersistedBytesAndUnverifiedPartialsAreDiscarded() = runTest {
|
||||
val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
|
||||
File(directory, "verified.apk").appendText("corrupt")
|
||||
|
|
@ -263,6 +337,26 @@ class HouseholdUpdateControllerTest {
|
|||
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
|
||||
}
|
||||
|
||||
@Test fun failuresIdentifyCheckDownloadAndInstallWithoutParsingMessages() = runTest {
|
||||
val controller = controller(this)
|
||||
releaseStatus = 403
|
||||
controller.check(true); advanceUntilIdle()
|
||||
assertEquals(UpdateOperation.Check, (controller.state.value as UpdateUiState.Failed).operation)
|
||||
releaseStatus = 200
|
||||
controller.retry(); advanceUntilIdle()
|
||||
assetStatus = 403
|
||||
controller.download(); advanceUntilIdle()
|
||||
assertEquals(UpdateOperation.Download, (controller.state.value as UpdateUiState.Failed).operation)
|
||||
assetStatus = 200
|
||||
controller.retry(); advanceUntilIdle()
|
||||
File(directory, "verified.apk").writeBytes(ByteArray(apk.size))
|
||||
controller.install(); advanceUntilIdle()
|
||||
val failed = controller.state.value as UpdateUiState.Failed
|
||||
assertEquals(UpdateOperation.Install, failed.operation)
|
||||
assertEquals(UpdateFailure.ChecksumMismatch, failed.kind)
|
||||
assertFalse(File(directory, "ready.json").exists())
|
||||
}
|
||||
|
||||
@Test fun interruptedWireDownloadDoesNotPersistReady() = runTest {
|
||||
truncated = true; wireTruncated = true
|
||||
val controller = controller(this); controller.check(true); advanceUntilIdle()
|
||||
|
|
@ -289,6 +383,8 @@ class HouseholdUpdateControllerTest {
|
|||
controller.install(); advanceUntilIdle()
|
||||
assertEquals(65L, controller.installedVersion.value.versionCode)
|
||||
assertTrue(File(directory, "verified.apk").exists())
|
||||
controller.cancel(); advanceUntilIdle(); controller.install(); advanceUntilIdle()
|
||||
assertEquals(1, platform.installs)
|
||||
platform.installed = platform.archive
|
||||
controller.onResume(); advanceUntilIdle()
|
||||
assertEquals(100001L, controller.installedVersion.value.versionCode)
|
||||
|
|
@ -306,6 +402,8 @@ class HouseholdUpdateControllerTest {
|
|||
replaced.handleInstallOutcome(InstallOutcome.Cancelled); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.Idle, replaced.state.value)
|
||||
assertFalse(File(directory, "verified.apk").exists())
|
||||
replaced.check(true); advanceUntilIdle()
|
||||
assertEquals(UpdateUiState.UpToDate, replaced.state.value)
|
||||
}
|
||||
|
||||
@Test fun reentryCheckCannotReplacePendingConfirmationOrStartAnotherSession() = runTest {
|
||||
|
|
|
|||
|
|
@ -21,19 +21,26 @@ class UpdateBehaviorTest {
|
|||
UpdateUiState.InstallCancelled to listOf(UpdateCommand.Install, UpdateCommand.Notes),
|
||||
UpdateUiState.Offline to listOf(UpdateCommand.Retry),
|
||||
UpdateUiState.NoInstaller to listOf(UpdateCommand.Retry, UpdateCommand.Classic),
|
||||
) + UpdateFailure.entries.map { UpdateUiState.Failed(it, "operation failed") to listOf(UpdateCommand.Retry, UpdateCommand.Back) }
|
||||
) + UpdateFailure.entries.map { UpdateUiState.Failed(it, "operation failed", UpdateOperation.Download) to listOf(UpdateCommand.Retry, UpdateCommand.Back) }
|
||||
expected.forEach { (state, commands) ->
|
||||
assertEquals(state.toString(), commands, presentUpdate(state, updateReleaseFixture()).actions.map { it.command })
|
||||
}
|
||||
assertNotEquals(presentUpdate(UpdateUiState.UpToDate, null), presentUpdate(UpdateUiState.NoReleasePublished, null))
|
||||
}
|
||||
|
||||
@Test fun refreshedInstalledIdentityDoesNotRequireOrMaskAChannelCheck() {
|
||||
val installed = "1.1.1"
|
||||
val refreshed = presentUpdate(UpdateUiState.Idle, null, installed)
|
||||
assertNotEquals(presentUpdate(UpdateUiState.Idle, null), refreshed)
|
||||
assertEquals(listOf(UpdateCommand.Check), refreshed.actions.map { it.command })
|
||||
assertEquals(presentUpdate(UpdateUiState.Checking, null),
|
||||
presentUpdate(UpdateUiState.Checking, null, installed))
|
||||
}
|
||||
|
||||
@Test fun percentageRequiresPositiveTotalAndIsBounded() {
|
||||
listOf(null, 0L, -1L).forEach { total ->
|
||||
val ui = presentUpdate(UpdateUiState.Downloading(20_100_000, total), updateReleaseFixture())
|
||||
assertNull(ui.fraction)
|
||||
assertTrue(ui.title.isEmpty())
|
||||
assertNotNull(ui.byteProgress)
|
||||
}
|
||||
assertEquals(.42f, presentUpdate(UpdateUiState.Downloading(20_076_000, 47_800_000), null).fraction!!, .0001f)
|
||||
assertEquals(1f, presentUpdate(UpdateUiState.Downloading(90, 30), null).fraction!!, 0f)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv.update
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.security.MessageDigest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
/** A replacement or incomplete stream must never reach the session commit boundary. */
|
||||
class UpdateSessionStreamTest {
|
||||
private val original = ByteArray(128 * 1024) { (it % 251).toByte() }
|
||||
private val asset = UpdateAsset("armeabi-v7a", "Wolfskin-armeabi-v7a.apk", original.size.toLong(),
|
||||
MessageDigest.getInstance("SHA-256").digest(original).toHexString(), 23)
|
||||
|
||||
@Test fun sameLengthReplacementAfterVerificationIsRejected() {
|
||||
val replaced = original.copyOf().also { it[it.lastIndex] = (it.last() + 1).toByte() }
|
||||
expectMismatch(replaced)
|
||||
}
|
||||
|
||||
@Test fun truncatedAndOversizedSessionStreamsAreRejected() {
|
||||
expectMismatch(original.copyOf(original.size - 1))
|
||||
expectMismatch(original + byteArrayOf(1))
|
||||
}
|
||||
|
||||
private fun expectMismatch(bytes: ByteArray) {
|
||||
try {
|
||||
bytes.inputStream().use { copyVerifiedUpdate(it, ByteArrayOutputStream(), asset) }
|
||||
fail("Changed installation bytes were accepted")
|
||||
} catch (error: UpdateException) {
|
||||
assertEquals(UpdateFailure.ChecksumMismatch, error.kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue