Integrate persistent updater metadata and failure operations
This commit is contained in:
commit
9bbb4bab25
15 changed files with 559 additions and 99 deletions
|
|
@ -12,7 +12,7 @@ The app uses Wolfskin launcher artwork and a lilac-on-`#07070a` theme by default
|
||||||
|
|
||||||
Settings opens on Requests. Press Down, then OK to open Updates; the row and screen show the installed app version. Left/Right selects an action, Down reaches Auto-check, and Up returns to the remembered action. Release notes keep focus inside the notes panel: Up/Down scrolls, and Back returns to the Release notes button.
|
Settings opens on Requests. Press Down, then OK to open Updates; the row and screen show the installed app version. Left/Right selects an action, Down reaches Auto-check, and Up returns to the remembered action. Release notes keep focus inside the notes panel: Up/Down scrolls, and Back returns to the Release notes button.
|
||||||
|
|
||||||
Auto-check uses the same preference as classic settings and only checks for updates. Manual checks remain available when it is off. Download and Install update are separate, explicit actions; Android owns installation permission and confirmation. Back can leave a download running, and returning to Updates shows the controller's current state.
|
Auto-check uses the same preference as classic settings and only checks for updates. Manual checks remain available when it is off. Download and Install update are separate, explicit actions; Android owns installation permission and confirmation. Returning from Android refreshes the installed version. Back can leave a download running, and returning to Updates shows the controller's current state.
|
||||||
|
|
||||||
Classic settings (trial) is the final Settings row. It opens the full original settings, including About → Update and More/Advanced → Updates. All existing settings groups remain available.
|
Classic settings (trial) is the final Settings row. It opens the full original settings, including About → Update and More/Advanced → Updates. All existing settings groups remain available.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ class HouseholdUpdateControllerTest {
|
||||||
private var truncated = false
|
private var truncated = false
|
||||||
private var chunked = false
|
private var chunked = false
|
||||||
private var unsupported = false
|
private var unsupported = false
|
||||||
|
private var manifestSigner = UpdateChannel.SIGNER
|
||||||
private var redirect = false
|
private var redirect = false
|
||||||
private var redirectTarget: String? = null
|
private var redirectTarget: String? = null
|
||||||
private val requests = mutableListOf<Map<String, List<String>>>()
|
private val requests = mutableListOf<Map<String, List<String>>>()
|
||||||
|
|
@ -49,12 +50,14 @@ 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, 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 {
|
||||||
override val autoCheck = MutableStateFlow(true)
|
override val autoCheck = MutableStateFlow(true)
|
||||||
|
|
@ -104,7 +107,7 @@ class HouseholdUpdateControllerTest {
|
||||||
@After fun stop() { server.stop(0) }
|
@After fun stop() { server.stop(0) }
|
||||||
|
|
||||||
private fun manifest() = UpdateManifest(1, UpdateChannel.PACKAGE, "1.1.1", code,
|
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(),
|
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)))
|
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)
|
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()
|
||||||
|
|
@ -163,6 +181,15 @@ class HouseholdUpdateControllerTest {
|
||||||
assertEquals(UpdateFailure.UnsupportedAbi, (unsupported.state.value as UpdateUiState.Failed).kind)
|
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 {
|
@Test fun checksumAndLengthFailuresNeverCommitReady() = runTest {
|
||||||
for (badChecksum in listOf(true, false)) {
|
for (badChecksum in listOf(true, false)) {
|
||||||
badHash = badChecksum; truncated = !badChecksum
|
badHash = badChecksum; truncated = !badChecksum
|
||||||
|
|
@ -243,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")
|
||||||
|
|
@ -263,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()
|
||||||
|
|
@ -289,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)
|
||||||
|
|
@ -306,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 {
|
||||||
|
|
|
||||||
|
|
@ -21,19 +21,26 @@ class UpdateBehaviorTest {
|
||||||
UpdateUiState.InstallCancelled to listOf(UpdateCommand.Install, UpdateCommand.Notes),
|
UpdateUiState.InstallCancelled to listOf(UpdateCommand.Install, UpdateCommand.Notes),
|
||||||
UpdateUiState.Offline to listOf(UpdateCommand.Retry),
|
UpdateUiState.Offline to listOf(UpdateCommand.Retry),
|
||||||
UpdateUiState.NoInstaller to listOf(UpdateCommand.Retry, UpdateCommand.Classic),
|
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) ->
|
expected.forEach { (state, commands) ->
|
||||||
assertEquals(state.toString(), commands, presentUpdate(state, updateReleaseFixture()).actions.map { it.command })
|
assertEquals(state.toString(), commands, presentUpdate(state, updateReleaseFixture()).actions.map { it.command })
|
||||||
}
|
}
|
||||||
assertNotEquals(presentUpdate(UpdateUiState.UpToDate, null), presentUpdate(UpdateUiState.NoReleasePublished, null))
|
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() {
|
@Test fun percentageRequiresPositiveTotalAndIsBounded() {
|
||||||
listOf(null, 0L, -1L).forEach { total ->
|
listOf(null, 0L, -1L).forEach { total ->
|
||||||
val ui = presentUpdate(UpdateUiState.Downloading(20_100_000, total), updateReleaseFixture())
|
val ui = presentUpdate(UpdateUiState.Downloading(20_100_000, total), updateReleaseFixture())
|
||||||
assertNull(ui.fraction)
|
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(.42f, presentUpdate(UpdateUiState.Downloading(20_076_000, 47_800_000), null).fraction!!, .0001f)
|
||||||
assertEquals(1f, presentUpdate(UpdateUiState.Downloading(90, 30), null).fraction!!, 0f)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,8 @@ import android.os.Build
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
@ -47,7 +49,7 @@ class AndroidUpdatePlatform @Inject constructor(@param:ApplicationContext privat
|
||||||
return try { context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); true } catch (_: RuntimeException) { false }
|
return try { context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); true } catch (_: RuntimeException) { false }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun install(file: File, outcome: (InstallOutcome) -> Unit) {
|
override fun install(file: File, asset: UpdateAsset, outcome: (InstallOutcome) -> Unit) {
|
||||||
if (context.noBackupFilesDir.usableSpace < file.length()) {
|
if (context.noBackupFilesDir.usableSpace < file.length()) {
|
||||||
outcome(InstallOutcome.InsufficientStorage)
|
outcome(InstallOutcome.InsufficientStorage)
|
||||||
return
|
return
|
||||||
|
|
@ -55,7 +57,7 @@ class AndroidUpdatePlatform @Inject constructor(@param:ApplicationContext privat
|
||||||
val installer = manager.packageInstaller
|
val installer = manager.packageInstaller
|
||||||
val parameters = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
|
val parameters = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
|
||||||
setAppPackageName(context.packageName)
|
setAppPackageName(context.packageName)
|
||||||
setSize(file.length())
|
setSize(asset.bytes)
|
||||||
if (Build.VERSION.SDK_INT >= 31) setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_REQUIRED)
|
if (Build.VERSION.SDK_INT >= 31) setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_REQUIRED)
|
||||||
}
|
}
|
||||||
var sessionId: Int? = null
|
var sessionId: Int? = null
|
||||||
|
|
@ -63,8 +65,8 @@ class AndroidUpdatePlatform @Inject constructor(@param:ApplicationContext privat
|
||||||
sessionId = installer.createSession(parameters)
|
sessionId = installer.createSession(parameters)
|
||||||
check(sessions.edit().putInt("id", sessionId).commit())
|
check(sessions.edit().putInt("id", sessionId).commit())
|
||||||
installer.openSession(sessionId).use { session ->
|
installer.openSession(sessionId).use { session ->
|
||||||
session.openWrite("Wolfskin.apk", 0, file.length()).use { output ->
|
session.openWrite("Wolfskin.apk", 0, asset.bytes).use { output ->
|
||||||
file.inputStream().use { it.copyTo(output) }
|
file.inputStream().use { copyVerifiedUpdate(it, output, asset) }
|
||||||
session.fsync(output)
|
session.fsync(output)
|
||||||
}
|
}
|
||||||
val intent = Intent(context, UpdateInstallReceiver::class.java).setAction("${context.packageName}.UPDATE_RESULT")
|
val intent = Intent(context, UpdateInstallReceiver::class.java).setAction("${context.packageName}.UPDATE_RESULT")
|
||||||
|
|
@ -72,8 +74,9 @@ class AndroidUpdatePlatform @Inject constructor(@param:ApplicationContext privat
|
||||||
val result = PendingIntent.getBroadcast(context, sessionId, intent, flags)
|
val result = PendingIntent.getBroadcast(context, sessionId, intent, flags)
|
||||||
session.commit(result.intentSender)
|
session.commit(result.intentSender)
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (error: Exception) {
|
||||||
sessionId?.let { runCatching { installer.abandonSession(it) } }
|
sessionId?.let { runCatching { installer.abandonSession(it) } }
|
||||||
|
if (error is UpdateException) throw error
|
||||||
outcome(InstallOutcome.Unavailable)
|
outcome(InstallOutcome.Unavailable)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -88,3 +91,21 @@ class AndroidUpdatePlatform @Inject constructor(@param:ApplicationContext privat
|
||||||
} catch (_: RuntimeException) { false }
|
} catch (_: RuntimeException) { false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Check the bytes actually staged into Android, not a prior read of the private path. */
|
||||||
|
internal fun copyVerifiedUpdate(input: InputStream, output: OutputStream, asset: UpdateAsset) {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
val buffer = ByteArray(64 * 1024)
|
||||||
|
var count = 0L
|
||||||
|
while (true) {
|
||||||
|
val length = input.read(buffer)
|
||||||
|
if (length < 0) break
|
||||||
|
count += length
|
||||||
|
if (count > asset.bytes) throw UpdateException(UpdateFailure.ChecksumMismatch, "APK changed while staging installation")
|
||||||
|
digest.update(buffer, 0, length)
|
||||||
|
output.write(buffer, 0, length)
|
||||||
|
}
|
||||||
|
if (count != asset.bytes || digest.digest().toHexString() != asset.sha256) {
|
||||||
|
throw UpdateException(UpdateFailure.ChecksumMismatch, "APK changed while staging installation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,50 +142,60 @@ 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() }
|
||||||
try { store.verify(store.apk, manifest, manifest.selectAsset(abis, sdk)) } catch (error: Exception) {
|
val asset = manifest.selectAsset(abis, sdk)
|
||||||
|
try {
|
||||||
|
store.verify(store.apk, manifest, asset)
|
||||||
|
if (!platform.hasInstallPermission()) {
|
||||||
|
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
|
store.clear(); ready = null
|
||||||
retryOperation = { if (candidate != null) download() else check(true) }
|
retryOperation = { if (candidate != null) download() else check(true) }
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
if (!platform.hasInstallPermission()) {
|
|
||||||
mutableState.value = if (platform.openPermissionSettings()) UpdateUiState.PermissionNeeded else UpdateUiState.NoInstaller
|
|
||||||
return@runOperation
|
|
||||||
}
|
|
||||||
mutableState.value = UpdateUiState.HandingOff
|
|
||||||
platform.install(store.apk, ::handleInstallOutcome)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
||||||
|
|
@ -186,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)
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ interface UpdateController {
|
||||||
|
|
||||||
fun check(manual: Boolean)
|
fun check(manual: Boolean)
|
||||||
fun download()
|
fun download()
|
||||||
/** Cancel partial bytes, or dismiss permission/handoff while retaining a verified APK. */
|
/** 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()
|
||||||
|
|
@ -38,11 +38,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,
|
||||||
|
|
|
||||||
|
|
@ -38,13 +38,12 @@ fun presentUpdate(state: UpdateUiState, release: UpdateUiState.Available?, updat
|
||||||
val retry = UpdateAction("Retry", UpdateCommand.Retry, true)
|
val retry = UpdateAction("Retry", UpdateCommand.Retry, true)
|
||||||
val metadata = updateMetadata(release)
|
val metadata = updateMetadata(release)
|
||||||
return when (state) {
|
return when (state) {
|
||||||
UpdateUiState.Idle -> UpdatePresentation(actions = listOf(UpdateAction("Check for updates", UpdateCommand.Check, true)))
|
UpdateUiState.Idle -> updatedVersion?.let(::updatedPresentation)
|
||||||
|
?: UpdatePresentation(actions = listOf(UpdateAction("Check for updates", UpdateCommand.Check, true)))
|
||||||
UpdateUiState.Checking -> UpdatePresentation(actions = listOf(back), progress = true)
|
UpdateUiState.Checking -> UpdatePresentation(actions = listOf(back), progress = true)
|
||||||
UpdateUiState.UpToDate -> UpdatePresentation(
|
UpdateUiState.UpToDate -> updatedVersion?.let(::updatedPresentation)
|
||||||
title = updatedVersion?.let { "Updated to $it" } ?: "Up to date",
|
?: UpdatePresentation("Up to date", "No newer release for this TV.",
|
||||||
summary = if (updatedVersion != null) "Sign-ins and settings kept." else "No newer release for this TV.",
|
actions = listOf(UpdateAction("Check again", UpdateCommand.Check, true)))
|
||||||
actions = listOf(UpdateAction("Check again", UpdateCommand.Check, true)),
|
|
||||||
)
|
|
||||||
UpdateUiState.NoReleasePublished -> UpdatePresentation("No release published", "No update has been published for this TV yet.",
|
UpdateUiState.NoReleasePublished -> UpdatePresentation("No release published", "No update has been published for this TV yet.",
|
||||||
actions = listOf(UpdateAction("Check again", UpdateCommand.Check, true)))
|
actions = listOf(UpdateAction("Check again", UpdateCommand.Check, true)))
|
||||||
is UpdateUiState.Available -> UpdatePresentation("Update available", updateMetadata(state),
|
is UpdateUiState.Available -> UpdatePresentation("Update available", updateMetadata(state),
|
||||||
|
|
@ -66,7 +65,7 @@ fun presentUpdate(state: UpdateUiState, release: UpdateUiState.Available?, updat
|
||||||
listOf("Wolfskin closes during installation.", "Choose Open, or reopen Wolfskin from the launcher."), listOf(UpdateAction("Back", UpdateCommand.Cancel)))
|
listOf("Wolfskin closes during installation.", "Choose Open, or reopen Wolfskin from the launcher."), listOf(UpdateAction("Back", UpdateCommand.Cancel)))
|
||||||
UpdateUiState.InstallCancelled -> UpdatePresentation("Install cancelled", metadata,
|
UpdateUiState.InstallCancelled -> UpdatePresentation("Install cancelled", metadata,
|
||||||
listOf("Verified · your sign-ins and settings stay."), listOf(UpdateAction("Try install again", UpdateCommand.Install, true), notes))
|
listOf("Verified · your sign-ins and settings stay."), listOf(UpdateAction("Try install again", UpdateCommand.Install, true), notes))
|
||||||
is UpdateUiState.Failed -> UpdatePresentation(failureTitle(state.kind), state.message, actions = listOf(retry, back))
|
is UpdateUiState.Failed -> UpdatePresentation(failureTitle(state.kind, state.operation), state.message, actions = listOf(retry, back))
|
||||||
UpdateUiState.Offline -> UpdatePresentation("No network", "Reconnect this TV, then try again.",
|
UpdateUiState.Offline -> UpdatePresentation("No network", "Reconnect this TV, then try again.",
|
||||||
actions = listOf(UpdateAction("Try again", UpdateCommand.Retry, true)))
|
actions = listOf(UpdateAction("Try again", UpdateCommand.Retry, true)))
|
||||||
UpdateUiState.NoInstaller -> UpdatePresentation("This TV cannot open an installer", "Android’s install confirmation is unavailable.",
|
UpdateUiState.NoInstaller -> UpdatePresentation("This TV cannot open an installer", "Android’s install confirmation is unavailable.",
|
||||||
|
|
@ -75,9 +74,19 @@ fun presentUpdate(state: UpdateUiState, release: UpdateUiState.Available?, updat
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun failureTitle(kind: UpdateFailure): String = when (kind) {
|
private fun updatedPresentation(version: String) = UpdatePresentation(
|
||||||
UpdateFailure.Network -> "Check failed"
|
"Updated to $version", "Sign-ins and settings kept.",
|
||||||
UpdateFailure.HttpRefused, UpdateFailure.InsufficientStorage -> "Download failed"
|
actions = listOf(UpdateAction("Check again", UpdateCommand.Check, true)),
|
||||||
UpdateFailure.InvalidMetadata, UpdateFailure.UnsupportedAbi -> "Update unavailable"
|
)
|
||||||
UpdateFailure.ChecksumMismatch, UpdateFailure.WrongPackage, UpdateFailure.WrongSigner, UpdateFailure.LowerVersionCode -> "Verification failed"
|
|
||||||
|
private fun failureTitle(kind: UpdateFailure, operation: UpdateOperation): String {
|
||||||
|
if (operation == UpdateOperation.Install) return "Install failed"
|
||||||
|
return when (kind) {
|
||||||
|
UpdateFailure.Network, UpdateFailure.HttpRefused, UpdateFailure.InsufficientStorage ->
|
||||||
|
if (operation == UpdateOperation.Check) "Check failed" else "Download failed"
|
||||||
|
UpdateFailure.InvalidMetadata ->
|
||||||
|
if (operation == UpdateOperation.Check) "Update unavailable" else "Verification failed"
|
||||||
|
UpdateFailure.UnsupportedAbi -> "Update unavailable"
|
||||||
|
UpdateFailure.ChecksumMismatch, UpdateFailure.WrongPackage, UpdateFailure.WrongSigner, UpdateFailure.LowerVersionCode -> "Verification failed"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,10 +71,10 @@ fun UpdatesScreen(shell: ShellState, controller: UpdateController, onBack: () ->
|
||||||
val installed by controller.installedVersion.collectAsStateWithLifecycle()
|
val installed by controller.installedVersion.collectAsStateWithLifecycle()
|
||||||
val release by controller.release.collectAsStateWithLifecycle()
|
val release by controller.release.collectAsStateWithLifecycle()
|
||||||
val autoCheck by controller.autoCheck.collectAsStateWithLifecycle()
|
val autoCheck by controller.autoCheck.collectAsStateWithLifecycle()
|
||||||
val initialInstalled = remember { installed.versionName }
|
val initialInstalledCode = remember { installed.versionCode }
|
||||||
val updatedVersion = installed.versionName.takeIf { it != initialInstalled }
|
val updatedVersion = installed.versionName.takeIf { installed.versionCode > initialInstalledCode }
|
||||||
val presentation = remember(state, release, updatedVersion) {
|
val presentation = remember(state, release, updatedVersion) {
|
||||||
presentUpdate(state, release, updatedVersion.takeIf { state == UpdateUiState.UpToDate })
|
presentUpdate(state, release, updatedVersion.takeIf { state == UpdateUiState.Idle || state == UpdateUiState.UpToDate })
|
||||||
}
|
}
|
||||||
val scroll = rememberScrollState()
|
val scroll = rememberScrollState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
|
||||||
|
|
@ -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>)
|
||||||
|
|
@ -13,12 +19,15 @@ interface UpdatePlatform {
|
||||||
fun inspect(file: File): ApkIdentity?
|
fun inspect(file: File): ApkIdentity?
|
||||||
fun hasInstallPermission(): Boolean
|
fun hasInstallPermission(): Boolean
|
||||||
fun openPermissionSettings(): Boolean
|
fun openPermissionSettings(): Boolean
|
||||||
fun install(file: File, outcome: (InstallOutcome) -> Unit)
|
fun install(file: File, asset: UpdateAsset, outcome: (InstallOutcome) -> Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,11 +72,13 @@ make tv-release VERSION=1.1.1 VERSION_CODE=100001 \
|
||||||
|
|
||||||
Required: a clean committed source checkout, the existing persistent key, native
|
Required: a clean committed source checkout, the existing persistent key, native
|
||||||
package credentials through the existing secret mount, no local AAR overrides,
|
package credentials through the existing secret mount, no local AAR overrides,
|
||||||
and an explicit code above every entry in `docker/tv-version-codes`. Known Opt
|
and an explicit code above the committed ledgers at HEAD and freshly fetched
|
||||||
code 65 seeds that ledger; old production code 28 and Wholphin code 62 belong
|
`origin/main`, plus every local consumed-code reservation. Fetch failure refuses
|
||||||
to different packages. Proposed first household code 100001 exceeds all three.
|
the build, including a dry-run. Known Opt code 65 seeds the ledger; old production
|
||||||
No household code derives from tag counts. Direct optimized Gradle tasks also
|
code 28 and Wholphin code 62 belong to different packages. Proposed first household
|
||||||
require `-PtvVersionCode`; the packaging command additionally enforces the ledger.
|
code 100001 exceeds all three. No household code derives from tag counts.
|
||||||
|
Only `make tv-release` produces an installable household release; direct optimized
|
||||||
|
Gradle builds bypass reservation and must never be installed on the household box.
|
||||||
|
|
||||||
The builder inspects final signed APKs with aapt2/apksigner and checks package,
|
The builder inspects final signed APKs with aapt2/apksigner and checks package,
|
||||||
version/code, signer, non-debuggable flag, minimum SDK, native ABI inventory,
|
version/code, signer, non-debuggable flag, minimum SDK, native ABI inventory,
|
||||||
|
|
@ -91,8 +93,13 @@ Manifest schema 1 contains `packageId`, `versionName`, `versionCode`,
|
||||||
`signerSha256`, and `assets[]` with `abi`, `name`, `bytes`, `sha256`, `minSdk`.
|
`signerSha256`, and `assets[]` with `abi`, `name`, `bytes`, `sha256`, `minSdk`.
|
||||||
All ABI outputs share one code/signer/identity. The local source tag is
|
All ABI outputs share one code/signer/identity. The local source tag is
|
||||||
`pwolfskin-opt-v<VERSION>`; the distribution tag is `wolfskin-opt-v<VERSION>`.
|
`pwolfskin-opt-v<VERSION>`; the distribution tag is `wolfskin-opt-v<VERSION>`.
|
||||||
|
Before building, the code is consumed in the local Git ref
|
||||||
|
`refs/wolfskin-tv/version-codes/<code>`. This survives discarding a working-tree
|
||||||
|
ledger append or checking out an older source commit. A failed real build also
|
||||||
|
consumes its code: use a higher code rather than deleting the reservation.
|
||||||
After successful packaging the builder appends the code to the tracked ledger.
|
After successful packaging the builder appends the code to the tracked ledger.
|
||||||
Commit that reservation and explicitly push the source tag before distribution.
|
Commit that reservation and explicitly push the source tag before distribution;
|
||||||
|
new producer checkouts obtain the committed floor by fetching `origin/main`.
|
||||||
An existing output directory is refused to preserve previous evidence.
|
An existing output directory is refused to preserve previous evidence.
|
||||||
|
|
||||||
Keep `private/` private: build/dependency logs, R8 mapping/effective configuration,
|
Keep `private/` private: build/dependency logs, R8 mapping/effective configuration,
|
||||||
|
|
@ -120,6 +127,12 @@ and reverified. Upload/verification failures remain draft. Promotion requires
|
||||||
the explicit flag; then anonymous latest/asset hash checks must pass. A failed
|
the explicit flag; then anonymous latest/asset hash checks must pass. A failed
|
||||||
post-promotion check attempts withdrawal to draft and reports failure, not
|
post-promotion check attempts withdrawal to draft and reports failure, not
|
||||||
success. Cross-origin credential forwarding and non-HTTPS URLs are refused.
|
success. Cross-origin credential forwarding and non-HTTPS URLs are refused.
|
||||||
|
|
||||||
|
Before creating/resuming a draft and immediately before promotion, the publisher
|
||||||
|
anonymously reads latest and its manifest. The bundle code must be strictly newer.
|
||||||
|
Only a latest 404 followed by a successful empty release list permits a first
|
||||||
|
release; other errors fail closed. A channel advance during upload leaves the
|
||||||
|
verified upload draft rather than publishing an equal or lower code.
|
||||||
`--test-server` permits only a loopback HTTP fake Forgejo; production defaults
|
`--test-server` permits only a loopback HTTP fake Forgejo; production defaults
|
||||||
cannot be redirected to a different repository by a CLI repository argument.
|
cannot be redirected to a different repository by a CLI repository argument.
|
||||||
Dry-run never fetches a credential or makes publication requests.
|
Dry-run never fetches a credential or makes publication requests.
|
||||||
|
|
@ -132,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.
|
||||||
|
|
@ -151,6 +174,8 @@ the installed Android signer is the installation boundary.
|
||||||
|
|
||||||
EOF never starts installation. Install requires a separate OK, per-package
|
EOF never starts installation. Install requires a separate OK, per-package
|
||||||
unknown-app permission and a confirmation-required PackageInstaller session.
|
unknown-app permission and a confirmation-required PackageInstaller session.
|
||||||
|
The bytes streamed into that session are checked again against the manifest length
|
||||||
|
and hash; any mismatch abandons the session before commit and discards invalid Ready.
|
||||||
Returning from permission settings requires another explicit Install press.
|
Returning from permission settings requires another explicit Install press.
|
||||||
Denial, cancellation and missing system handlers retain the verified download.
|
Denial, cancellation and missing system handlers retain the verified download.
|
||||||
No broad FileProvider, public Downloads write, silent install or automatic
|
No broad FileProvider, public Downloads write, silent install or automatic
|
||||||
|
|
@ -228,13 +253,15 @@ monotonic versionCode; source commit identity is recorded separately.
|
||||||
`make apk` remains the old `org.justinvisser.wolfskin.tv` production assembly and
|
`make apk` remains the old `org.justinvisser.wolfskin.tv` production assembly and
|
||||||
release signer. It must never publish to the household channel.
|
release signer. It must never publish to the household channel.
|
||||||
|
|
||||||
Build all four default-flavor outputs in one invocation through the container
|
For build-only diagnostics, build all four outputs through the container wrapper
|
||||||
wrapper, without a release keystore. On a host whose Gradle cache already holds
|
with a deliberately non-release code. **Direct optimized builds must never be
|
||||||
the pinned native extensions and their metadata:
|
installed on the household box or published.** Use `make tv-release` for every
|
||||||
|
household installation. On a host whose Gradle cache already holds the pinned
|
||||||
|
native extensions and their metadata:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
bash docker/gradle.sh optimized --offline \
|
bash docker/gradle.sh optimized --offline \
|
||||||
-PtvVersion=1.1.1 -PtvVersionCode=100001 \
|
-PtvVersion=1.1.1 -PtvVersionCode=1 \
|
||||||
-PWholphinExtensionsUsername=JustinZeus \
|
-PWholphinExtensionsUsername=JustinZeus \
|
||||||
-PWholphinExtensionsPassword=unused \
|
-PWholphinExtensionsPassword=unused \
|
||||||
:app:assembleDefaultOptimized
|
:app:assembleDefaultOptimized
|
||||||
|
|
@ -247,7 +274,8 @@ through the existing `ORG_GRADLE_PROJECT_WholphinExtensionsUsername` and
|
||||||
or securely seeded cached artifacts. No release signing inputs are needed.
|
or securely seeded cached artifacts. No release signing inputs are needed.
|
||||||
Without native extension configuration, the existing dependency selection uses
|
Without native extension configuration, the existing dependency selection uses
|
||||||
the MPV stub and omits ffmpeg/AV1, just like `make apk-debug`; that is not a
|
the MPV stub and omits ffmpeg/AV1, just like `make apk-debug`; that is not a
|
||||||
playback-complete comparison. Inspect native libraries before installing.
|
playback-complete comparison. Inspect native libraries for diagnostic evidence;
|
||||||
|
do not install these direct-build APKs on the household box.
|
||||||
|
|
||||||
Outputs are in `app/build/outputs/apk/default/optimized/`: universal, arm64-v8a,
|
Outputs are in `app/build/outputs/apk/default/optimized/`: universal, arm64-v8a,
|
||||||
armeabi-v7a and x86_64. Keep
|
armeabi-v7a and x86_64. Keep
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,12 @@ class FakeForge:
|
||||||
self.corrupt_draft = False
|
self.corrupt_draft = False
|
||||||
self.corrupt_public = False
|
self.corrupt_public = False
|
||||||
self.redirect_url: str | None = None
|
self.redirect_url: str | None = None
|
||||||
|
self.published_code: int | None = None
|
||||||
|
self.channel_manifest: dict[str, Any] = {}
|
||||||
|
self.latest_status = 200
|
||||||
|
self.release_list: list[dict[str, Any]] = []
|
||||||
|
self.latest_reads = 0
|
||||||
|
self.advance_before_promote: int | None = None
|
||||||
fixture = self
|
fixture = self
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
|
@ -104,10 +110,39 @@ class FakeForge:
|
||||||
else fixture.corrupt_public
|
else fixture.corrupt_public
|
||||||
)
|
)
|
||||||
self.respond(data + b"corrupt" if corrupt else data)
|
self.respond(data + b"corrupt" if corrupt else data)
|
||||||
elif self.path.endswith("/latest") and fixture.release.get(
|
elif self.path == "/channel/wolfskin-update.json":
|
||||||
"draft", True
|
self.respond(
|
||||||
):
|
fixture.channel_manifest
|
||||||
self.respond({}, 404)
|
| {"versionCode": fixture.published_code}
|
||||||
|
)
|
||||||
|
elif self.path.endswith("/latest"):
|
||||||
|
fixture.latest_reads += 1
|
||||||
|
if (
|
||||||
|
fixture.latest_reads == 2
|
||||||
|
and fixture.advance_before_promote is not None
|
||||||
|
):
|
||||||
|
fixture.published_code = fixture.advance_before_promote
|
||||||
|
if fixture.latest_status != 200:
|
||||||
|
self.respond({}, fixture.latest_status)
|
||||||
|
elif fixture.release and not fixture.release["draft"]:
|
||||||
|
self.respond(fixture.release)
|
||||||
|
elif fixture.published_code is not None:
|
||||||
|
self.respond(
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"name": "wolfskin-update.json",
|
||||||
|
"browser_download_url": fixture.base
|
||||||
|
+ "/channel/wolfskin-update.json",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.respond({}, 404)
|
||||||
|
elif urllib.parse.urlsplit(self.path).path.endswith("/releases"):
|
||||||
|
self.respond(fixture.release_list)
|
||||||
else:
|
else:
|
||||||
self.respond(fixture.release)
|
self.respond(fixture.release)
|
||||||
|
|
||||||
|
|
@ -157,6 +192,7 @@ class PublishTest(unittest.TestCase):
|
||||||
"signerSha256": SIGNER,
|
"signerSha256": SIGNER,
|
||||||
"assets": assets,
|
"assets": assets,
|
||||||
}
|
}
|
||||||
|
self.forge.channel_manifest = manifest
|
||||||
(self.directory / "wolfskin-update.json").write_text(json.dumps(manifest))
|
(self.directory / "wolfskin-update.json").write_text(json.dumps(manifest))
|
||||||
(self.directory / "RELEASE-NOTES.md").write_text("Fixture release notes")
|
(self.directory / "RELEASE-NOTES.md").write_text("Fixture release notes")
|
||||||
self.checksums()
|
self.checksums()
|
||||||
|
|
@ -208,16 +244,18 @@ class PublishTest(unittest.TestCase):
|
||||||
self.assertEqual(0, result.returncode, result.stderr)
|
self.assertEqual(0, result.returncode, result.stderr)
|
||||||
self.assertTrue(self.forge.release["draft"])
|
self.assertTrue(self.forge.release["draft"])
|
||||||
self.assertEqual(REQUIRED, set(self.forge.assets))
|
self.assertEqual(REQUIRED, set(self.forge.assets))
|
||||||
self.assertTrue(all(auth for _, auth in self.forge.events))
|
self.assertTrue(
|
||||||
|
all(
|
||||||
|
not auth
|
||||||
|
for path, auth in self.forge.events
|
||||||
|
if "/latest" in path or "?limit=" in path
|
||||||
|
)
|
||||||
|
)
|
||||||
result = self.cli("--draft-id", "17", "--promote")
|
result = self.cli("--draft-id", "17", "--promote")
|
||||||
self.assertEqual(0, result.returncode, result.stderr)
|
self.assertEqual(0, result.returncode, result.stderr)
|
||||||
self.assertFalse(self.forge.release["draft"])
|
self.assertFalse(self.forge.release["draft"])
|
||||||
for name, data in self.forge.assets.items():
|
for name, data in self.forge.assets.items():
|
||||||
self.assertEqual((self.directory / name).read_bytes(), data)
|
self.assertEqual((self.directory / name).read_bytes(), data)
|
||||||
anonymous = [
|
|
||||||
event for event, authenticated in self.forge.events if not authenticated
|
|
||||||
]
|
|
||||||
self.assertEqual(8, len(anonymous))
|
|
||||||
|
|
||||||
def test_corrupt_uploaded_bytes_remain_draft(self) -> None:
|
def test_corrupt_uploaded_bytes_remain_draft(self) -> None:
|
||||||
self.forge.corrupt_draft = True
|
self.forge.corrupt_draft = True
|
||||||
|
|
@ -266,6 +304,59 @@ class PublishTest(unittest.TestCase):
|
||||||
self.assertEqual(0, result.returncode, result.stderr)
|
self.assertEqual(0, result.returncode, result.stderr)
|
||||||
self.assertFalse(trace.exists())
|
self.assertFalse(trace.exists())
|
||||||
|
|
||||||
|
def test_equal_and_lower_bundle_codes_never_create_a_draft(self) -> None:
|
||||||
|
for published_code in (100001, 100002):
|
||||||
|
with self.subTest(published_code=published_code):
|
||||||
|
self.forge.published_code = published_code
|
||||||
|
result = self.cli("--promote")
|
||||||
|
self.assertNotEqual(0, result.returncode)
|
||||||
|
self.assertEqual({}, self.forge.release)
|
||||||
|
self.assertTrue(all(not auth for _, auth in self.forge.events))
|
||||||
|
|
||||||
|
def test_newer_bundle_reads_live_manifest_anonymously(self) -> None:
|
||||||
|
self.forge.published_code = 100000
|
||||||
|
result = self.cli()
|
||||||
|
self.assertEqual(0, result.returncode, result.stderr)
|
||||||
|
self.assertTrue(self.forge.release["draft"])
|
||||||
|
live_reads = [
|
||||||
|
auth
|
||||||
|
for path, auth in self.forge.events
|
||||||
|
if path.endswith("/latest") or path.startswith("GET /channel/")
|
||||||
|
]
|
||||||
|
self.assertTrue(live_reads)
|
||||||
|
self.assertFalse(any(live_reads))
|
||||||
|
|
||||||
|
def test_channel_advance_during_upload_prevents_promotion(self) -> None:
|
||||||
|
self.forge.advance_before_promote = 100001
|
||||||
|
result = self.cli("--promote")
|
||||||
|
self.assertNotEqual(0, result.returncode)
|
||||||
|
self.assertTrue(self.forge.release["draft"])
|
||||||
|
self.assertFalse(any(path == "PATCH" for path, _ in self.forge.events))
|
||||||
|
|
||||||
|
def test_empty_channel_404_permits_first_draft(self) -> None:
|
||||||
|
result = self.cli()
|
||||||
|
self.assertEqual(0, result.returncode, result.stderr)
|
||||||
|
self.assertTrue(self.forge.release["draft"])
|
||||||
|
self.assertIn(
|
||||||
|
(
|
||||||
|
"GET /api/v1/repos/justinzeus/wolfskin-tv-updates/releases?limit=1",
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
self.forge.events,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_missing_latest_with_nonempty_channel_is_not_first_release(self) -> None:
|
||||||
|
self.forge.release_list = [{"id": 9}]
|
||||||
|
result = self.cli()
|
||||||
|
self.assertNotEqual(0, result.returncode)
|
||||||
|
self.assertEqual({}, self.forge.release)
|
||||||
|
|
||||||
|
def test_refused_live_channel_never_creates_draft(self) -> None:
|
||||||
|
self.forge.latest_status = 403
|
||||||
|
result = self.cli()
|
||||||
|
self.assertNotEqual(0, result.returncode)
|
||||||
|
self.assertEqual({}, self.forge.release)
|
||||||
|
|
||||||
def test_authenticated_redirect_never_reaches_another_origin(self) -> None:
|
def test_authenticated_redirect_never_reaches_another_origin(self) -> None:
|
||||||
other = FakeForge()
|
other = FakeForge()
|
||||||
self.addCleanup(other.close)
|
self.addCleanup(other.close)
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,12 @@ class ReleaseInputTest(unittest.TestCase):
|
||||||
"-m",
|
"-m",
|
||||||
"Record used code",
|
"Record used code",
|
||||||
)
|
)
|
||||||
|
self.remote = Path(self.temporary.name) / "origin.git"
|
||||||
|
subprocess.run(
|
||||||
|
["git", "init", "--bare", "--quiet", str(self.remote)], check=True
|
||||||
|
)
|
||||||
|
self.git("remote", "add", "origin", str(self.remote))
|
||||||
|
self.git("push", "--quiet", "origin", "HEAD:main")
|
||||||
self.previous_root = tv_release.ROOT
|
self.previous_root = tv_release.ROOT
|
||||||
tv_release.ROOT = self.root
|
tv_release.ROOT = self.root
|
||||||
self.addCleanup(setattr, tv_release, "ROOT", self.previous_root)
|
self.addCleanup(setattr, tv_release, "ROOT", self.previous_root)
|
||||||
|
|
@ -94,6 +100,59 @@ class ReleaseInputTest(unittest.TestCase):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
"65 installed-opt\n", (self.root / "docker/tv-version-codes").read_text()
|
"65 installed-opt\n", (self.root / "docker/tv-version-codes").read_text()
|
||||||
)
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"", self.git("for-each-ref", "refs/wolfskin-tv/version-codes/")
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_stale_branch_uses_fetched_main_ledger(self) -> None:
|
||||||
|
stale = self.git("rev-parse", "HEAD")
|
||||||
|
(self.root / "docker/tv-version-codes").write_text(
|
||||||
|
"65 installed-opt\n100001 released\n"
|
||||||
|
)
|
||||||
|
self.git("add", "docker/tv-version-codes")
|
||||||
|
self.git(
|
||||||
|
"-c",
|
||||||
|
"user.name=Fixture",
|
||||||
|
"-c",
|
||||||
|
"user.email=fixture@example.invalid",
|
||||||
|
"commit",
|
||||||
|
"--quiet",
|
||||||
|
"-m",
|
||||||
|
"Reserve published code",
|
||||||
|
)
|
||||||
|
self.git("push", "--quiet", "origin", "HEAD:main")
|
||||||
|
self.git("checkout", "--quiet", "--detach", stale)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
tv_release.validate_inputs("1.1.2", 100001)
|
||||||
|
self.assertEqual(stale, tv_release.validate_inputs("1.1.2", 100002))
|
||||||
|
|
||||||
|
def test_discarding_ledger_append_cannot_reuse_reserved_code(self) -> None:
|
||||||
|
commit = self.git("rev-parse", "HEAD")
|
||||||
|
tv_release.reserve_code(100001, commit)
|
||||||
|
with (self.root / "docker/tv-version-codes").open("a") as ledger:
|
||||||
|
ledger.write("100001 1.1.1 consumed\n")
|
||||||
|
self.git("restore", "docker/tv-version-codes")
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
tv_release.validate_inputs("1.1.2", 100001)
|
||||||
|
self.assertEqual(commit, tv_release.validate_inputs("1.1.2", 100002))
|
||||||
|
|
||||||
|
def test_head_reservation_wins_over_older_main(self) -> None:
|
||||||
|
(self.root / "docker/tv-version-codes").write_text(
|
||||||
|
"65 installed-opt\n100002 released\n"
|
||||||
|
)
|
||||||
|
self.git("add", "docker/tv-version-codes")
|
||||||
|
self.git(
|
||||||
|
"-c",
|
||||||
|
"user.name=Fixture",
|
||||||
|
"-c",
|
||||||
|
"user.email=fixture@example.invalid",
|
||||||
|
"commit",
|
||||||
|
"--quiet",
|
||||||
|
"-m",
|
||||||
|
"Reserve newer branch code",
|
||||||
|
)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
tv_release.validate_inputs("1.1.3", 100002)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,14 @@ REQUIRED = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ForgeHttpError(RuntimeError):
|
||||||
|
"""Retain HTTP status without exposing response bodies or authorization."""
|
||||||
|
|
||||||
|
def __init__(self, method: str, status: int) -> None:
|
||||||
|
super().__init__(f"Forgejo {method} failed: HTTP {status}")
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
|
||||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||||
"""Validate every redirect before forwarding distribution-tool credentials."""
|
"""Validate every redirect before forwarding distribution-tool credentials."""
|
||||||
|
|
||||||
|
|
@ -98,9 +106,9 @@ class Forge:
|
||||||
return self.opener.open(request, timeout=60)
|
return self.opener.open(request, timeout=60)
|
||||||
except urllib.error.HTTPError as error:
|
except urllib.error.HTTPError as error:
|
||||||
if method != "GET" or error.code not in (301, 302, 303, 307, 308):
|
if method != "GET" or error.code not in (301, 302, 303, 307, 308):
|
||||||
raise RuntimeError(
|
status = error.code
|
||||||
f"Forgejo {method} failed: HTTP {error.code}"
|
error.close()
|
||||||
) from None
|
raise ForgeHttpError(method, status) from None
|
||||||
location = error.headers.get("Location")
|
location = error.headers.get("Location")
|
||||||
error.close()
|
error.close()
|
||||||
if not location:
|
if not location:
|
||||||
|
|
@ -159,6 +167,41 @@ class Forge:
|
||||||
if actual != expected:
|
if actual != expected:
|
||||||
raise ValueError("Uploaded artifact checksum mismatch")
|
raise ValueError("Uploaded artifact checksum mismatch")
|
||||||
|
|
||||||
|
def require_newer_code(self, code: int) -> None:
|
||||||
|
"""Read the public channel anonymously and fail closed on an uncertain floor."""
|
||||||
|
try:
|
||||||
|
release = self.json("GET", "/releases/latest", authenticated=False)
|
||||||
|
except ForgeHttpError as error:
|
||||||
|
if error.status != 404:
|
||||||
|
raise
|
||||||
|
releases = self.json("GET", "/releases?limit=1", authenticated=False)
|
||||||
|
if releases == []:
|
||||||
|
return
|
||||||
|
raise ValueError("Latest is missing but the channel is not empty") from None
|
||||||
|
assets = [
|
||||||
|
asset
|
||||||
|
for asset in release["assets"]
|
||||||
|
if asset["name"] == "wolfskin-update.json"
|
||||||
|
]
|
||||||
|
if len(assets) != 1:
|
||||||
|
raise ValueError("Latest release must have exactly one update manifest")
|
||||||
|
with self.request(
|
||||||
|
"GET", assets[0]["browser_download_url"], authenticated=False
|
||||||
|
) as response:
|
||||||
|
manifest = json.load(response)
|
||||||
|
latest_code = manifest["versionCode"]
|
||||||
|
if (
|
||||||
|
manifest["schemaVersion"] != 1
|
||||||
|
or manifest["packageId"] != "org.justinvisser.wolfskin.tv.opt"
|
||||||
|
or type(latest_code) is not int
|
||||||
|
or not 1 <= latest_code <= 2100000000
|
||||||
|
):
|
||||||
|
raise ValueError("Invalid live household version identity")
|
||||||
|
if code <= latest_code:
|
||||||
|
raise ValueError(
|
||||||
|
f"Bundle versionCode must exceed live channel code {latest_code}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def credential(server: str) -> str:
|
def credential(server: str) -> str:
|
||||||
"""Read git's existing credential helper without secrets in argv, stdout or disk."""
|
"""Read git's existing credential helper without secrets in argv, stdout or disk."""
|
||||||
|
|
@ -280,6 +323,7 @@ def publish(
|
||||||
f"Dry run: draft {REPOSITORY} {tag}; upload and verify {len(hashes)} assets; promote={promote}; no requests or credentials"
|
f"Dry run: draft {REPOSITORY} {tag}; upload and verify {len(hashes)} assets; promote={promote}; no requests or credentials"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
Forge(server, "", test_server).require_newer_code(manifest["versionCode"])
|
||||||
forge = Forge(server, credential(server), test_server)
|
forge = Forge(server, credential(server), test_server)
|
||||||
if draft_id is None:
|
if draft_id is None:
|
||||||
release = forge.json(
|
release = forge.json(
|
||||||
|
|
@ -313,6 +357,7 @@ def publish(
|
||||||
"Draft upload verified. Publication requires owner approval and explicit --promote."
|
"Draft upload verified. Publication requires owner approval and explicit --promote."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
forge.require_newer_code(manifest["versionCode"])
|
||||||
try:
|
try:
|
||||||
forge.json("PATCH", f"/releases/{release_id}", {"draft": False})
|
forge.json("PATCH", f"/releases/{release_id}", {"draft": False})
|
||||||
latest = forge.json("GET", "/releases/latest", authenticated=False)
|
latest = forge.json("GET", "/releases/latest", authenticated=False)
|
||||||
|
|
|
||||||
|
|
@ -37,16 +37,40 @@ def validate_inputs(version: str, code: int) -> str:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Release source must be clean, including the version-code ledger"
|
"Release source must be clean, including the version-code ledger"
|
||||||
)
|
)
|
||||||
used = [
|
commit = run("git", "rev-parse", "HEAD")
|
||||||
int(line.split()[0])
|
run(
|
||||||
for line in (ROOT / "docker/tv-version-codes").read_text().splitlines()
|
"git",
|
||||||
if line and not line.startswith("#")
|
"fetch",
|
||||||
]
|
"--no-tags",
|
||||||
|
"origin",
|
||||||
|
"refs/heads/main:refs/remotes/origin/main",
|
||||||
|
)
|
||||||
|
used = []
|
||||||
|
for reference in (commit, "refs/remotes/origin/main"):
|
||||||
|
if run("git", "ls-tree", "--name-only", reference, "docker/tv-version-codes"):
|
||||||
|
ledger = run("git", "show", f"{reference}:docker/tv-version-codes")
|
||||||
|
used.extend(
|
||||||
|
int(line.split()[0])
|
||||||
|
for line in ledger.splitlines()
|
||||||
|
if line and not line.startswith("#")
|
||||||
|
)
|
||||||
|
reservations = run(
|
||||||
|
"git",
|
||||||
|
"for-each-ref",
|
||||||
|
"--format=%(refname:strip=3)",
|
||||||
|
"refs/wolfskin-tv/version-codes/",
|
||||||
|
)
|
||||||
|
used.extend(int(value) for value in reservations.splitlines())
|
||||||
if code <= max(used):
|
if code <= max(used):
|
||||||
raise ValueError(f"VERSION_CODE must exceed ledger maximum {max(used)}")
|
raise ValueError(f"VERSION_CODE must exceed reserved maximum {max(used)}")
|
||||||
if list((ROOT / "app/libs").glob("*.aar")):
|
if list((ROOT / "app/libs").glob("*.aar")):
|
||||||
raise ValueError("Local AAR overrides are forbidden for household releases")
|
raise ValueError("Local AAR overrides are forbidden for household releases")
|
||||||
return run("git", "rev-parse", "HEAD")
|
return commit
|
||||||
|
|
||||||
|
|
||||||
|
def reserve_code(code: int, commit: str) -> None:
|
||||||
|
"""Keep a consumed code reserved even if its working-tree ledger append is discarded."""
|
||||||
|
run("git", "update-ref", f"refs/wolfskin-tv/version-codes/{code}", commit, "")
|
||||||
|
|
||||||
|
|
||||||
def inspect_apk(path: Path, version: str, code: int, abi: str, proof: Path) -> int:
|
def inspect_apk(path: Path, version: str, code: int, abi: str, proof: Path) -> int:
|
||||||
|
|
@ -130,6 +154,7 @@ def release(version: str, code: int, output: Path, dry_run: bool) -> None:
|
||||||
existing = run("git", "tag", "--list", tag)
|
existing = run("git", "tag", "--list", tag)
|
||||||
if existing and run("git", "rev-parse", f"{tag}^{{commit}}") != commit:
|
if existing and run("git", "rev-parse", f"{tag}^{{commit}}") != commit:
|
||||||
raise ValueError("Source tag already identifies another commit")
|
raise ValueError("Source tag already identifies another commit")
|
||||||
|
reserve_code(code, commit)
|
||||||
output.mkdir(parents=True)
|
output.mkdir(parents=True)
|
||||||
private = output / "private"
|
private = output / "private"
|
||||||
private.mkdir(mode=0o700)
|
private.mkdir(mode=0o700)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue