From 51799669e7a47cdb9dc11247884a0ff32d2ffbcb Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sat, 7 Nov 2026 20:29:58 +0100 Subject: [PATCH] Expose persistent update metadata and safe UI dismissals --- .../update/HouseholdUpdateControllerTest.kt | 92 ++++++++++++++++++- .../tv/update/FakeUpdateController.kt | 8 +- .../tv/update/HouseholdUpdateController.kt | 73 ++++++++++----- .../wolfskin/tv/update/UpdateController.kt | 7 +- .../wolfskin/tv/update/VerifiedUpdateStore.kt | 26 ++++-- docker/README.md | 10 ++ 6 files changed, 181 insertions(+), 35 deletions(-) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/update/HouseholdUpdateControllerTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/update/HouseholdUpdateControllerTest.kt index 1a7929f1..fc9c246b 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/update/HouseholdUpdateControllerTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/update/HouseholdUpdateControllerTest.kt @@ -50,11 +50,13 @@ 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 openPermissionSettings(): Boolean { settingsOpens++; return settingsAvailable } override fun install(file: File, asset: UpdateAsset, outcome: (InstallOutcome) -> Unit) { installs++; outcome(this.outcome) } } private val preferences = object : UpdatePreferences { @@ -116,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() @@ -253,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") @@ -273,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() @@ -299,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) @@ -316,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 { diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/FakeUpdateController.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/FakeUpdateController.kt index 3976489b..ebac04ed 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/FakeUpdateController.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/FakeUpdateController.kt @@ -9,16 +9,22 @@ class FakeUpdateController( initialState: UpdateUiState = UpdateUiState.Idle, initialAutoCheck: Boolean = true, initialVersion: InstalledVersion = InstalledVersion("1.1.1", 100001), + initialRelease: UpdateUiState.Available? = initialState as? UpdateUiState.Available, ) : UpdateController { private val mutableState = MutableStateFlow(initialState) private val mutableAutoCheck = MutableStateFlow(initialAutoCheck) private val mutableVersion = MutableStateFlow(initialVersion) + private val mutableRelease = MutableStateFlow(initialRelease) override val state = mutableState.asStateFlow() override val autoCheck = mutableAutoCheck.asStateFlow() override val installedVersion = mutableVersion.asStateFlow() + override val release = mutableRelease.asStateFlow() val actions = mutableListOf() - 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 } override fun check(manual: Boolean) { actions += "check:$manual" } override fun download() { actions += "download" } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/HouseholdUpdateController.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/HouseholdUpdateController.kt index 9955f1cb..091be7a9 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/HouseholdUpdateController.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/HouseholdUpdateController.kt @@ -40,26 +40,32 @@ class HouseholdUpdateController( ) : UpdateController { private val mutableState = MutableStateFlow(UpdateUiState.Idle) private val mutableVersion = MutableStateFlow(platform.installed().version) + private val mutableRelease = MutableStateFlow(null) override val state = mutableState.asStateFlow() override val installedVersion = mutableVersion.asStateFlow() override val autoCheck = preferences.autoCheck + override val release = mutableRelease.asStateFlow() private var candidate: UpdateCandidate? = null private var ready: UpdateManifest? = null private var operation: Job? = null + @Volatile private var installerActive = false private var retryOperation: () -> Unit = { check(true) } private val restored = scope.async { - ready = store.restore(abis, sdk) - ready?.let { mutableState.value = UpdateUiState.Ready(it.versionName) } + store.restore(abis, sdk)?.let { + ready = it.manifest + mutableRelease.value = available(it.manifest, it.asset, it.notes) + mutableState.value = UpdateUiState.Ready(it.manifest.versionName) + } } @Synchronized - private fun runOperation(action: suspend () -> Unit): Unit { + private fun runOperation(operationKind: UpdateOperation, action: suspend () -> Unit): Unit { if (operation?.isActive == true) return operation = scope.launch { restored.await() try { action() } 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 (_: ConnectException) { mutableState.value = UpdateUiState.Offline } catch (error: IOException) { @@ -67,16 +73,16 @@ class HouseholdUpdateController( val full = state.value is UpdateUiState.Downloading && release != null && store.directory.usableSpace < release.asset.bytes 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 = - 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 (!manual && !preferences.enabled()) return@runOperation ready?.let { manifest -> @@ -86,25 +92,34 @@ class HouseholdUpdateController( retryOperation = { check(true) } mutableState.value = UpdateUiState.Checking 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() mutableVersion.value = installed.version 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 (release.manifest.versionCode < installed.version.versionCode) throw UpdateException(UpdateFailure.LowerVersionCode, "Channel release is older than this installation") if (release.manifest.versionCode == installed.version.versionCode) { - candidate = null + candidate = null; mutableRelease.value = null 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()) - .withZone(ZoneId.systemDefault()).format(Instant.parse(release.manifest.publishedAt)) - return UpdateUiState.Available(release.manifest.versionName, release.asset.bytes, date, release.notes) + .withZone(ZoneId.systemDefault()).format(Instant.parse(manifest.publishedAt)) + 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 (ready != null) return@runOperation val release = candidate ?: return@runOperation @@ -115,7 +130,7 @@ class HouseholdUpdateController( try { transport.download(release, store.partial) { done, total -> mutableState.value = UpdateUiState.Downloading(done, total) } currentCoroutineContext().ensureActive() - store.commit(release.manifest, release.asset) + store.commit(release.manifest, release.asset, release.notes) currentCoroutineContext().ensureActive() ready = release.manifest mutableState.value = UpdateUiState.Ready(release.manifest.versionName) @@ -127,20 +142,19 @@ class HouseholdUpdateController( @Synchronized override fun cancel() { - if (systemOwnsFlow(state.value)) return transport.cancel() val previous = operation - previous?.cancel() + if (!installerActive) previous?.cancel() operation = scope.launch { previous?.join() store.partial.delete() 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 { - if (state.value == UpdateUiState.HandingOff) return@runOperation + override fun install(): Unit = runOperation(UpdateOperation.Install) { + if (installerActive || state.value == UpdateUiState.HandingOff) return@runOperation val manifest = ready ?: return@runOperation retryOperation = { install() } val asset = manifest.selectAsset(abis, sdk) @@ -150,9 +164,15 @@ class HouseholdUpdateController( mutableState.value = if (platform.openPermissionSettings()) UpdateUiState.PermissionNeeded else UpdateUiState.NoInstaller return@runOperation } + currentCoroutineContext().ensureActive() + installerActive = true mutableState.value = UpdateUiState.HandingOff platform.install(store.apk, asset, ::handleInstallOutcome) + } catch (error: CancellationException) { + installerActive = false + throw error } catch (error: Exception) { + installerActive = false store.clear(); ready = null retryOperation = { if (candidate != null) download() else check(true) } throw error @@ -162,18 +182,20 @@ class HouseholdUpdateController( fun handleInstallOutcome(outcome: InstallOutcome) { scope.launch { restored.await() + installerActive = outcome == InstallOutcome.Pending || outcome == InstallOutcome.Success if (ready == null && outcome != InstallOutcome.Pending) { + installerActive = false retryOperation = { check(true) } mutableState.value = UpdateUiState.Idle onResume() return@launch } 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.Unavailable -> UpdateUiState.NoInstaller - InstallOutcome.InsufficientStorage -> UpdateUiState.Failed(UpdateFailure.InsufficientStorage, "Android needs more storage to install the retained update") - InstallOutcome.Failed -> UpdateUiState.Failed(UpdateFailure.InvalidMetadata, "Android could not install the update; the verified download is retained") + 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", UpdateOperation.Install) InstallOutcome.Success -> UpdateUiState.HandingOff } if (outcome != InstallOutcome.Pending) onResume() @@ -189,7 +211,8 @@ class HouseholdUpdateController( mutableVersion.value = platform.installed().version val manifest = ready ?: return@launch 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) { // Returning from settings requires another explicit Install press. mutableState.value = if (platform.hasInstallPermission()) UpdateUiState.Ready(manifest.versionName) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateController.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateController.kt index c7faa29e..b46bb0e2 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateController.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateController.kt @@ -8,9 +8,11 @@ interface UpdateController { val state: StateFlow val autoCheck: StateFlow val installedVersion: StateFlow + val release: StateFlow fun check(manual: Boolean) fun download() + /** Discard partial bytes; retain verified bytes and restore Ready without duplicating a system session. */ fun cancel() fun install() fun retry() @@ -35,11 +37,14 @@ sealed interface UpdateUiState { data object PermissionNeeded : UpdateUiState data object HandingOff : 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 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. */ enum class UpdateFailure { Network, HttpRefused, InvalidMetadata, UnsupportedAbi, InsufficientStorage, diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/VerifiedUpdateStore.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/VerifiedUpdateStore.kt index d5f406b4..a28e80c1 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/VerifiedUpdateStore.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/VerifiedUpdateStore.kt @@ -3,6 +3,12 @@ package org.justinvisser.wolfskin.tv.update import java.io.File 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. */ data class ApkIdentity(val packageId: String, val version: InstalledVersion, val signers: Set) @@ -19,6 +25,9 @@ interface UpdatePlatform { /** Installer results do not imply the running process has been replaced. */ 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. */ class VerifiedUpdateStore(val directory: File, private val platform: UpdatePlatform) { 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) check(partial.renameTo(apk)) { "Could not commit verified APK" } 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" } } /** Re-hash and re-inspect after process death; a marker alone never establishes trust. */ - fun restore(abis: List, sdk: Int): UpdateManifest? { + fun restore(abis: List, sdk: Int): VerifiedRelease? { partial.delete() File(directory, "ready.pending").delete() if (!record.exists()) { apk.delete(); return null } return try { - val manifest = UpdateManifest.parse(record.readText()) - verify(apk, manifest, manifest.selectAsset(abis, sdk)) - manifest + val text = record.readText() + val manifest = UpdateManifest.parse(text) + 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 } } diff --git a/docker/README.md b/docker/README.md index e35d146e..92dc7adf 100644 --- a/docker/README.md +++ b/docker/README.md @@ -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 controller; the old package retains its original updater. +`release: StateFlow` 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: HTTPS, no URL user-info, no cookies/Jellyfin token/repository credential. Loopback HTTP is available only through the test transport constructor flag.