Expose persistent update metadata and safe UI dismissals
All checks were successful
review/safety-opus Delta PASS: B1 closed; seams keep install invariants

This commit is contained in:
justinzeus 2026-11-07 20:29:58 +01:00
parent 383802611c
commit 51799669e7
6 changed files with 181 additions and 35 deletions

View file

@ -50,11 +50,13 @@ class HouseholdUpdateControllerTest {
var permission = true var permission = true
var settingsAvailable = true var settingsAvailable = true
var installs = 0 var installs = 0
var settingsOpens = 0
var inspectAction: (() -> Unit)? = null
var outcome = InstallOutcome.Cancelled var outcome = InstallOutcome.Cancelled
override fun installed() = installed 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 hasInstallPermission() = permission
override fun openPermissionSettings() = settingsAvailable override fun openPermissionSettings(): Boolean { settingsOpens++; return settingsAvailable }
override fun install(file: File, asset: UpdateAsset, outcome: (InstallOutcome) -> Unit) { installs++; outcome(this.outcome) } override fun install(file: File, asset: UpdateAsset, outcome: (InstallOutcome) -> Unit) { installs++; outcome(this.outcome) }
} }
private val preferences = object : UpdatePreferences { private val preferences = object : UpdatePreferences {
@ -116,17 +118,32 @@ class HouseholdUpdateControllerTest {
val controller = controller(this) val controller = controller(this)
controller.check(true); advanceUntilIdle() controller.check(true); advanceUntilIdle()
assertTrue(controller.state.value is UpdateUiState.Available) assertTrue(controller.state.value is UpdateUiState.Available)
val metadata = controller.release.value
assertEquals(controller.state.value, metadata)
controller.download(); advanceUntilIdle() controller.download(); advanceUntilIdle()
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value) assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
assertEquals(metadata, controller.release.value)
assertEquals(0, platform.installs) assertEquals(0, platform.installs)
val restored = controller(this); advanceUntilIdle() val restored = controller(this); advanceUntilIdle()
assertEquals(UpdateUiState.Ready("1.1.1"), restored.state.value) assertEquals(UpdateUiState.Ready("1.1.1"), restored.state.value)
assertEquals(metadata, restored.release.value)
restored.install(); advanceUntilIdle() restored.install(); advanceUntilIdle()
assertEquals(UpdateUiState.InstallCancelled, restored.state.value) assertEquals(UpdateUiState.InstallCancelled, restored.state.value)
assertEquals(1, platform.installs) assertEquals(1, platform.installs)
assertTrue(File(directory, "verified.apk").exists()) 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 { @Test fun equalCodeIsUpToDateAndOlderCodeIsNotASuccess() = runTest {
code = 65 code = 65
val equal = controller(this); equal.check(true); advanceUntilIdle() val equal = controller(this); equal.check(true); advanceUntilIdle()
@ -253,6 +270,53 @@ class HouseholdUpdateControllerTest {
assertEquals(UpdateUiState.NoInstaller, controller.state.value) 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 { @Test fun corruptPersistedBytesAndUnverifiedPartialsAreDiscarded() = runTest {
val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle() val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
File(directory, "verified.apk").appendText("corrupt") File(directory, "verified.apk").appendText("corrupt")
@ -273,6 +337,26 @@ class HouseholdUpdateControllerTest {
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value) 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 { @Test fun interruptedWireDownloadDoesNotPersistReady() = runTest {
truncated = true; wireTruncated = true truncated = true; wireTruncated = true
val controller = controller(this); controller.check(true); advanceUntilIdle() val controller = controller(this); controller.check(true); advanceUntilIdle()
@ -299,6 +383,8 @@ class HouseholdUpdateControllerTest {
controller.install(); advanceUntilIdle() controller.install(); advanceUntilIdle()
assertEquals(65L, controller.installedVersion.value.versionCode) assertEquals(65L, controller.installedVersion.value.versionCode)
assertTrue(File(directory, "verified.apk").exists()) assertTrue(File(directory, "verified.apk").exists())
controller.cancel(); advanceUntilIdle(); controller.install(); advanceUntilIdle()
assertEquals(1, platform.installs)
platform.installed = platform.archive platform.installed = platform.archive
controller.onResume(); advanceUntilIdle() controller.onResume(); advanceUntilIdle()
assertEquals(100001L, controller.installedVersion.value.versionCode) assertEquals(100001L, controller.installedVersion.value.versionCode)
@ -316,6 +402,8 @@ class HouseholdUpdateControllerTest {
replaced.handleInstallOutcome(InstallOutcome.Cancelled); advanceUntilIdle() replaced.handleInstallOutcome(InstallOutcome.Cancelled); advanceUntilIdle()
assertEquals(UpdateUiState.Idle, replaced.state.value) assertEquals(UpdateUiState.Idle, replaced.state.value)
assertFalse(File(directory, "verified.apk").exists()) assertFalse(File(directory, "verified.apk").exists())
replaced.check(true); advanceUntilIdle()
assertEquals(UpdateUiState.UpToDate, replaced.state.value)
} }
@Test fun reentryCheckCannotReplacePendingConfirmationOrStartAnotherSession() = runTest { @Test fun reentryCheckCannotReplacePendingConfirmationOrStartAnotherSession() = runTest {

View file

@ -9,16 +9,22 @@ class FakeUpdateController(
initialState: UpdateUiState = UpdateUiState.Idle, initialState: UpdateUiState = UpdateUiState.Idle,
initialAutoCheck: Boolean = true, initialAutoCheck: Boolean = true,
initialVersion: InstalledVersion = InstalledVersion("1.1.1", 100001), initialVersion: InstalledVersion = InstalledVersion("1.1.1", 100001),
initialRelease: UpdateUiState.Available? = initialState as? UpdateUiState.Available,
) : UpdateController { ) : UpdateController {
private val mutableState = MutableStateFlow(initialState) private val mutableState = MutableStateFlow(initialState)
private val mutableAutoCheck = MutableStateFlow(initialAutoCheck) private val mutableAutoCheck = MutableStateFlow(initialAutoCheck)
private val mutableVersion = MutableStateFlow(initialVersion) private val mutableVersion = MutableStateFlow(initialVersion)
private val mutableRelease = MutableStateFlow(initialRelease)
override val state = mutableState.asStateFlow() override val state = mutableState.asStateFlow()
override val autoCheck = mutableAutoCheck.asStateFlow() override val autoCheck = mutableAutoCheck.asStateFlow()
override val installedVersion = mutableVersion.asStateFlow() override val installedVersion = mutableVersion.asStateFlow()
override val release = mutableRelease.asStateFlow()
val actions = mutableListOf<String>() val actions = mutableListOf<String>()
fun show(state: UpdateUiState) { mutableState.value = state } fun show(state: UpdateUiState) {
if (state is UpdateUiState.Available) mutableRelease.value = state
mutableState.value = state
}
fun showInstalled(version: InstalledVersion) { mutableVersion.value = version } fun showInstalled(version: InstalledVersion) { mutableVersion.value = version }
override fun check(manual: Boolean) { actions += "check:$manual" } override fun check(manual: Boolean) { actions += "check:$manual" }
override fun download() { actions += "download" } override fun download() { actions += "download" }

View file

@ -40,26 +40,32 @@ class HouseholdUpdateController(
) : UpdateController { ) : UpdateController {
private val mutableState = MutableStateFlow<UpdateUiState>(UpdateUiState.Idle) private val mutableState = MutableStateFlow<UpdateUiState>(UpdateUiState.Idle)
private val mutableVersion = MutableStateFlow(platform.installed().version) private val mutableVersion = MutableStateFlow(platform.installed().version)
private val mutableRelease = MutableStateFlow<UpdateUiState.Available?>(null)
override val state = mutableState.asStateFlow() override val state = mutableState.asStateFlow()
override val installedVersion = mutableVersion.asStateFlow() override val installedVersion = mutableVersion.asStateFlow()
override val autoCheck = preferences.autoCheck override val autoCheck = preferences.autoCheck
override val release = mutableRelease.asStateFlow()
private var candidate: UpdateCandidate? = null private var candidate: UpdateCandidate? = null
private var ready: UpdateManifest? = null private var ready: UpdateManifest? = null
private var operation: Job? = null private var operation: Job? = null
@Volatile private var installerActive = false
private var retryOperation: () -> Unit = { check(true) } private var retryOperation: () -> Unit = { check(true) }
private val restored = scope.async { private val restored = scope.async {
ready = store.restore(abis, sdk) store.restore(abis, sdk)?.let {
ready?.let { mutableState.value = UpdateUiState.Ready(it.versionName) } ready = it.manifest
mutableRelease.value = available(it.manifest, it.asset, it.notes)
mutableState.value = UpdateUiState.Ready(it.manifest.versionName)
}
} }
@Synchronized @Synchronized
private fun runOperation(action: suspend () -> Unit): Unit { private fun runOperation(operationKind: UpdateOperation, action: suspend () -> Unit): Unit {
if (operation?.isActive == true) return if (operation?.isActive == true) return
operation = scope.launch { operation = scope.launch {
restored.await() restored.await()
try { action() } try { action() }
catch (error: CancellationException) { throw error } catch (error: CancellationException) { throw error }
catch (error: UpdateException) { mutableState.value = UpdateUiState.Failed(error.kind, error.message) } catch (error: UpdateException) { mutableState.value = UpdateUiState.Failed(error.kind, error.message, operationKind) }
catch (_: UnknownHostException) { mutableState.value = UpdateUiState.Offline } catch (_: UnknownHostException) { mutableState.value = UpdateUiState.Offline }
catch (_: ConnectException) { mutableState.value = UpdateUiState.Offline } catch (_: ConnectException) { mutableState.value = UpdateUiState.Offline }
catch (error: IOException) { catch (error: IOException) {
@ -67,16 +73,16 @@ class HouseholdUpdateController(
val full = state.value is UpdateUiState.Downloading && release != null && val full = state.value is UpdateUiState.Downloading && release != null &&
store.directory.usableSpace < release.asset.bytes store.directory.usableSpace < release.asset.bytes
mutableState.value = UpdateUiState.Failed(if (full) UpdateFailure.InsufficientStorage else UpdateFailure.Network, mutableState.value = UpdateUiState.Failed(if (full) UpdateFailure.InsufficientStorage else UpdateFailure.Network,
if (full) "Not enough storage for the update" else "Update transfer failed; try again") if (full) "Not enough storage for the update" else "Update transfer failed; try again", operationKind)
} }
catch (_: Exception) { mutableState.value = UpdateUiState.Failed(UpdateFailure.InvalidMetadata, "Update could not be processed") } catch (_: Exception) { mutableState.value = UpdateUiState.Failed(UpdateFailure.InvalidMetadata, "Update could not be processed", operationKind) }
} }
} }
private fun systemOwnsFlow(current: UpdateUiState): Boolean = private fun systemOwnsFlow(current: UpdateUiState): Boolean =
current == UpdateUiState.HandingOff || current == UpdateUiState.PermissionNeeded installerActive || current == UpdateUiState.HandingOff || current == UpdateUiState.PermissionNeeded
override fun check(manual: Boolean): Unit = runOperation { override fun check(manual: Boolean): Unit = runOperation(UpdateOperation.Check) {
if (systemOwnsFlow(state.value)) return@runOperation if (systemOwnsFlow(state.value)) return@runOperation
if (!manual && !preferences.enabled()) return@runOperation if (!manual && !preferences.enabled()) return@runOperation
ready?.let { manifest -> ready?.let { manifest ->
@ -86,25 +92,34 @@ class HouseholdUpdateController(
retryOperation = { check(true) } retryOperation = { check(true) }
mutableState.value = UpdateUiState.Checking mutableState.value = UpdateUiState.Checking
val release = transport.latest(preferences.url(), abis, sdk) val release = transport.latest(preferences.url(), abis, sdk)
if (release == null) { mutableState.value = UpdateUiState.NoReleasePublished; return@runOperation } if (release == null) {
candidate = null; mutableRelease.value = null
mutableState.value = UpdateUiState.NoReleasePublished
return@runOperation
}
val installed = platform.installed() val installed = platform.installed()
mutableVersion.value = installed.version mutableVersion.value = installed.version
if (release.manifest.packageId != installed.packageId) throw UpdateException(UpdateFailure.WrongPackage, "Release belongs to a different application") if (release.manifest.packageId != installed.packageId) throw UpdateException(UpdateFailure.WrongPackage, "Release belongs to a different application")
if (installed.signers != setOf(release.manifest.signerSha256)) throw UpdateException(UpdateFailure.WrongSigner, "Release signer differs from this installation") if (installed.signers != setOf(release.manifest.signerSha256)) throw UpdateException(UpdateFailure.WrongSigner, "Release signer differs from this installation")
if (release.manifest.versionCode < installed.version.versionCode) throw UpdateException(UpdateFailure.LowerVersionCode, "Channel release is older than this installation") if (release.manifest.versionCode < installed.version.versionCode) throw UpdateException(UpdateFailure.LowerVersionCode, "Channel release is older than this installation")
if (release.manifest.versionCode == installed.version.versionCode) { if (release.manifest.versionCode == installed.version.versionCode) {
candidate = null candidate = null; mutableRelease.value = null
mutableState.value = UpdateUiState.UpToDate mutableState.value = UpdateUiState.UpToDate
} else { candidate = release; mutableState.value = available(release) } } else {
candidate = release
val metadata = available(release.manifest, release.asset, release.notes)
mutableRelease.value = metadata
mutableState.value = metadata
}
} }
private fun available(release: UpdateCandidate): UpdateUiState.Available { private fun available(manifest: UpdateManifest, asset: UpdateAsset, notes: String): UpdateUiState.Available {
val date = DateTimeFormatter.ofLocalizedDate(java.time.format.FormatStyle.MEDIUM).withLocale(Locale.getDefault()) val date = DateTimeFormatter.ofLocalizedDate(java.time.format.FormatStyle.MEDIUM).withLocale(Locale.getDefault())
.withZone(ZoneId.systemDefault()).format(Instant.parse(release.manifest.publishedAt)) .withZone(ZoneId.systemDefault()).format(Instant.parse(manifest.publishedAt))
return UpdateUiState.Available(release.manifest.versionName, release.asset.bytes, date, release.notes) return UpdateUiState.Available(manifest.versionName, asset.bytes, date, notes)
} }
override fun download(): Unit = runOperation { override fun download(): Unit = runOperation(UpdateOperation.Download) {
if (systemOwnsFlow(state.value)) return@runOperation if (systemOwnsFlow(state.value)) return@runOperation
if (ready != null) return@runOperation if (ready != null) return@runOperation
val release = candidate ?: return@runOperation val release = candidate ?: return@runOperation
@ -115,7 +130,7 @@ class HouseholdUpdateController(
try { try {
transport.download(release, store.partial) { done, total -> mutableState.value = UpdateUiState.Downloading(done, total) } transport.download(release, store.partial) { done, total -> mutableState.value = UpdateUiState.Downloading(done, total) }
currentCoroutineContext().ensureActive() currentCoroutineContext().ensureActive()
store.commit(release.manifest, release.asset) store.commit(release.manifest, release.asset, release.notes)
currentCoroutineContext().ensureActive() currentCoroutineContext().ensureActive()
ready = release.manifest ready = release.manifest
mutableState.value = UpdateUiState.Ready(release.manifest.versionName) mutableState.value = UpdateUiState.Ready(release.manifest.versionName)
@ -127,20 +142,19 @@ class HouseholdUpdateController(
@Synchronized @Synchronized
override fun cancel() { override fun cancel() {
if (systemOwnsFlow(state.value)) return
transport.cancel() transport.cancel()
val previous = operation val previous = operation
previous?.cancel() if (!installerActive) previous?.cancel()
operation = scope.launch { operation = scope.launch {
previous?.join() previous?.join()
store.partial.delete() store.partial.delete()
mutableState.value = ready?.let { UpdateUiState.Ready(it.versionName) } mutableState.value = ready?.let { UpdateUiState.Ready(it.versionName) }
?: candidate?.let { available(it) } ?: UpdateUiState.Idle ?: candidate?.let { mutableRelease.value } ?: UpdateUiState.Idle
} }
} }
override fun install(): Unit = runOperation { override fun install(): Unit = runOperation(UpdateOperation.Install) {
if (state.value == UpdateUiState.HandingOff) return@runOperation if (installerActive || state.value == UpdateUiState.HandingOff) return@runOperation
val manifest = ready ?: return@runOperation val manifest = ready ?: return@runOperation
retryOperation = { install() } retryOperation = { install() }
val asset = manifest.selectAsset(abis, sdk) val asset = manifest.selectAsset(abis, sdk)
@ -150,9 +164,15 @@ class HouseholdUpdateController(
mutableState.value = if (platform.openPermissionSettings()) UpdateUiState.PermissionNeeded else UpdateUiState.NoInstaller mutableState.value = if (platform.openPermissionSettings()) UpdateUiState.PermissionNeeded else UpdateUiState.NoInstaller
return@runOperation return@runOperation
} }
currentCoroutineContext().ensureActive()
installerActive = true
mutableState.value = UpdateUiState.HandingOff mutableState.value = UpdateUiState.HandingOff
platform.install(store.apk, asset, ::handleInstallOutcome) platform.install(store.apk, asset, ::handleInstallOutcome)
} catch (error: CancellationException) {
installerActive = false
throw error
} catch (error: Exception) { } catch (error: Exception) {
installerActive = false
store.clear(); ready = null store.clear(); ready = null
retryOperation = { if (candidate != null) download() else check(true) } retryOperation = { if (candidate != null) download() else check(true) }
throw error throw error
@ -162,18 +182,20 @@ class HouseholdUpdateController(
fun handleInstallOutcome(outcome: InstallOutcome) { fun handleInstallOutcome(outcome: InstallOutcome) {
scope.launch { scope.launch {
restored.await() restored.await()
installerActive = outcome == InstallOutcome.Pending || outcome == InstallOutcome.Success
if (ready == null && outcome != InstallOutcome.Pending) { if (ready == null && outcome != InstallOutcome.Pending) {
installerActive = false
retryOperation = { check(true) } retryOperation = { check(true) }
mutableState.value = UpdateUiState.Idle mutableState.value = UpdateUiState.Idle
onResume() onResume()
return@launch return@launch
} }
mutableState.value = when (outcome) { mutableState.value = when (outcome) {
InstallOutcome.Pending -> UpdateUiState.HandingOff InstallOutcome.Pending -> if (state.value is UpdateUiState.Ready) state.value else UpdateUiState.HandingOff
InstallOutcome.Cancelled -> UpdateUiState.InstallCancelled InstallOutcome.Cancelled -> UpdateUiState.InstallCancelled
InstallOutcome.Unavailable -> UpdateUiState.NoInstaller InstallOutcome.Unavailable -> UpdateUiState.NoInstaller
InstallOutcome.InsufficientStorage -> UpdateUiState.Failed(UpdateFailure.InsufficientStorage, "Android needs more storage to install the retained update") InstallOutcome.InsufficientStorage -> UpdateUiState.Failed(UpdateFailure.InsufficientStorage, "Android needs more storage to install the retained update", UpdateOperation.Install)
InstallOutcome.Failed -> UpdateUiState.Failed(UpdateFailure.InvalidMetadata, "Android could not install the update; the verified download is retained") InstallOutcome.Failed -> UpdateUiState.Failed(UpdateFailure.InvalidMetadata, "Android could not install the update; the verified download is retained", UpdateOperation.Install)
InstallOutcome.Success -> UpdateUiState.HandingOff InstallOutcome.Success -> UpdateUiState.HandingOff
} }
if (outcome != InstallOutcome.Pending) onResume() if (outcome != InstallOutcome.Pending) onResume()
@ -189,7 +211,8 @@ class HouseholdUpdateController(
mutableVersion.value = platform.installed().version mutableVersion.value = platform.installed().version
val manifest = ready ?: return@launch val manifest = ready ?: return@launch
if (mutableVersion.value.versionCode >= manifest.versionCode) { if (mutableVersion.value.versionCode >= manifest.versionCode) {
store.clear(); ready = null; candidate = null; mutableState.value = UpdateUiState.Idle installerActive = false
store.clear(); ready = null; candidate = null; mutableRelease.value = null; mutableState.value = UpdateUiState.Idle
} else if (state.value == UpdateUiState.PermissionNeeded) { } else if (state.value == UpdateUiState.PermissionNeeded) {
// Returning from settings requires another explicit Install press. // Returning from settings requires another explicit Install press.
mutableState.value = if (platform.hasInstallPermission()) UpdateUiState.Ready(manifest.versionName) mutableState.value = if (platform.hasInstallPermission()) UpdateUiState.Ready(manifest.versionName)

View file

@ -8,9 +8,11 @@ interface UpdateController {
val state: StateFlow<UpdateUiState> val state: StateFlow<UpdateUiState>
val autoCheck: StateFlow<Boolean> val autoCheck: StateFlow<Boolean>
val installedVersion: StateFlow<InstalledVersion> val installedVersion: StateFlow<InstalledVersion>
val release: StateFlow<UpdateUiState.Available?>
fun check(manual: Boolean) fun check(manual: Boolean)
fun download() fun download()
/** Discard partial bytes; retain verified bytes and restore Ready without duplicating a system session. */
fun cancel() fun cancel()
fun install() fun install()
fun retry() fun retry()
@ -35,11 +37,14 @@ sealed interface UpdateUiState {
data object PermissionNeeded : UpdateUiState data object PermissionNeeded : UpdateUiState
data object HandingOff : UpdateUiState data object HandingOff : UpdateUiState
data object InstallCancelled : UpdateUiState data object InstallCancelled : UpdateUiState
data class Failed(val kind: UpdateFailure, val message: String) : UpdateUiState data class Failed(val kind: UpdateFailure, val message: String, val operation: UpdateOperation) : UpdateUiState
data object Offline : UpdateUiState data object Offline : UpdateUiState
data object NoInstaller : UpdateUiState data object NoInstaller : UpdateUiState
} }
/** The failed action is structured; UI must not infer it from a reason string. */
enum class UpdateOperation { Check, Download, Install }
/** Failures are distinct from a successful version comparison or an empty channel. */ /** Failures are distinct from a successful version comparison or an empty channel. */
enum class UpdateFailure { enum class UpdateFailure {
Network, HttpRefused, InvalidMetadata, UnsupportedAbi, InsufficientStorage, Network, HttpRefused, InvalidMetadata, UnsupportedAbi, InsufficientStorage,

View file

@ -3,6 +3,12 @@ package org.justinvisser.wolfskin.tv.update
import java.io.File import java.io.File
import java.security.MessageDigest import java.security.MessageDigest
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
/** Actual APK identity, read independently of channel metadata. */ /** Actual APK identity, read independently of channel metadata. */
data class ApkIdentity(val packageId: String, val version: InstalledVersion, val signers: Set<String>) data class ApkIdentity(val packageId: String, val version: InstalledVersion, val signers: Set<String>)
@ -19,6 +25,9 @@ interface UpdatePlatform {
/** Installer results do not imply the running process has been replaced. */ /** Installer results do not imply the running process has been replaced. */
enum class InstallOutcome { Pending, Success, Cancelled, Failed, Unavailable, InsufficientStorage } enum class InstallOutcome { Pending, Success, Cancelled, Failed, Unavailable, InsufficientStorage }
/** Metadata returned only after verifying the persisted APK against this release. */
data class VerifiedRelease(val manifest: UpdateManifest, val asset: UpdateAsset, val notes: String)
/** Private staging; a metadata commit occurs only after independently verifying the APK. */ /** Private staging; a metadata commit occurs only after independently verifying the APK. */
class VerifiedUpdateStore(val directory: File, private val platform: UpdatePlatform) { class VerifiedUpdateStore(val directory: File, private val platform: UpdatePlatform) {
val partial = File(directory, "download.part") val partial = File(directory, "download.part")
@ -52,23 +61,28 @@ class VerifiedUpdateStore(val directory: File, private val platform: UpdatePlatf
} }
} }
fun commit(manifest: UpdateManifest, asset: UpdateAsset) { fun commit(manifest: UpdateManifest, asset: UpdateAsset, notes: String) {
verify(partial, manifest, asset) verify(partial, manifest, asset)
check(partial.renameTo(apk)) { "Could not commit verified APK" } check(partial.renameTo(apk)) { "Could not commit verified APK" }
val temporary = File(directory, "ready.pending") val temporary = File(directory, "ready.pending")
temporary.outputStream().use { it.write(UpdateManifest.encode(manifest).toByteArray()); it.fd.sync() } val metadata = Json.parseToJsonElement(UpdateManifest.encode(manifest)).jsonObject
val stored = JsonObject(metadata + ("releaseNotes" to JsonPrimitive(notes)))
temporary.outputStream().use { it.write(stored.toString().toByteArray()); it.fd.sync() }
check(temporary.renameTo(record)) { "Could not commit verified metadata" } check(temporary.renameTo(record)) { "Could not commit verified metadata" }
} }
/** Re-hash and re-inspect after process death; a marker alone never establishes trust. */ /** Re-hash and re-inspect after process death; a marker alone never establishes trust. */
fun restore(abis: List<String>, sdk: Int): UpdateManifest? { fun restore(abis: List<String>, sdk: Int): VerifiedRelease? {
partial.delete() partial.delete()
File(directory, "ready.pending").delete() File(directory, "ready.pending").delete()
if (!record.exists()) { apk.delete(); return null } if (!record.exists()) { apk.delete(); return null }
return try { return try {
val manifest = UpdateManifest.parse(record.readText()) val text = record.readText()
verify(apk, manifest, manifest.selectAsset(abis, sdk)) val manifest = UpdateManifest.parse(text)
manifest val asset = manifest.selectAsset(abis, sdk)
verify(apk, manifest, asset)
val notes = Json.parseToJsonElement(text).jsonObject["releaseNotes"]?.jsonPrimitive?.contentOrNull.orEmpty()
VerifiedRelease(manifest, asset, notes)
} catch (_: Exception) { clear(); null } } catch (_: Exception) { clear(); null }
} }

View file

@ -145,6 +145,16 @@ explicit check/download/cancel/install/retry/resume actions. Its capture fake
does not perform real operations. Classic Opt update controls use that same does not perform real operations. Classic Opt update controls use that same
controller; the old package retains its original updater. controller; the old package retains its original updater.
`release: StateFlow<UpdateUiState.Available?>` retains version, size, localized
date and release notes through download, Ready and installation cancellation.
Verified records persist notes as an optional `releaseNotes` field alongside
the manifest; existing manifest-only records remain readable. `Failed.operation`
is `UpdateOperation.Check`, `Download` or `Install`, never inferred from message text.
Permission and handoff dismissals restore verified Ready without discarding its
APK. Installer ownership is tracked separately from the visible state, so a
dismissed but active system session still blocks a duplicate handoff. Calling
`install()` from PermissionNeeded resolves and opens the permission settings again.
The client uses a dedicated anonymous OkHttp client, validating every redirect: The client uses a dedicated anonymous OkHttp client, validating every redirect:
HTTPS, no URL user-info, no cookies/Jellyfin token/repository credential. HTTPS, no URL user-info, no cookies/Jellyfin token/repository credential.
Loopback HTTP is available only through the test transport constructor flag. Loopback HTTP is available only through the test transport constructor flag.