Integrate the published updater core

This commit is contained in:
justinzeus 2026-11-07 20:02:04 +01:00
commit 94a69de25f
31 changed files with 2501 additions and 109 deletions

View file

@ -18,6 +18,16 @@ import java.nio.file.Paths
import kotlin.io.path.readText
class TestUpdateChecker {
@Test
fun householdChannelMigrationDoesNotChangeCustomOrOldProductionFeeds() {
val channel = org.justinvisser.wolfskin.tv.update.UpdateChannel
Assert.assertEquals(channel.LATEST_URL, channel.migrateUrl(channel.PACKAGE, channel.LEGACY_URL))
Assert.assertEquals("https://custom.test/releases/latest", channel.migrateUrl(channel.PACKAGE, "https://custom.test/releases/latest"))
Assert.assertEquals(channel.LEGACY_URL, channel.migrateUrl("org.justinvisser.wolfskin.tv", channel.LEGACY_URL))
Assert.assertEquals(channel.LEGACY_URL, channel.defaultUrl("org.justinvisser.wolfskin.tv"))
Assert.assertEquals(channel.LATEST_URL, channel.defaultUrl(channel.PACKAGE))
}
lateinit var releaseJson: JsonObject
val assetsJson: JsonArray by lazy { releaseJson["assets"]!!.jsonArray }

View file

@ -22,7 +22,14 @@ class VersionCompareTests {
@Test
fun optimizedVersionRemainsCompatibleWithUpgradeAndUpdateChecks() {
val optimizedVersion = Version.fromString(requireNotNull(System.getProperty("wolfskin.optimizedVersionName")))
Assert.assertEquals(Version.fromString(BuildConfig.VERSION_NAME), optimizedVersion)
Assert.assertTrue(optimizedVersion.isGreaterThan(Version.fromString("1.1.0-140-g5162c299")))
}
@Test
fun householdAvailabilityOrdersAndroidCodesNotDisplayVersions() {
Assert.assertTrue(org.justinvisser.wolfskin.tv.update.UpdateChannel.isNewer(100001, 65))
Assert.assertFalse(org.justinvisser.wolfskin.tv.update.UpdateChannel.isNewer(65, 100001))
Assert.assertFalse(org.justinvisser.wolfskin.tv.update.UpdateChannel.isNewer(100001, 100001))
}
@Test

View file

@ -68,7 +68,7 @@ import javax.inject.Singleton
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [DeviceModule::class, AppModule::class],
replaces = [DeviceModule::class, AppModule::class, org.justinvisser.wolfskin.tv.update.UpdateModule::class],
)
object TestModule {
val testDispatcher = StandardTestDispatcher()

View file

@ -0,0 +1,357 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv.update
import com.sun.net.httpserver.HttpServer
import java.io.File
import java.net.InetSocketAddress
import java.security.MessageDigest
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
/** Exercises the production transport, verification store and state machine against HTTP bytes. */
@OptIn(ExperimentalCoroutinesApi::class)
class HouseholdUpdateControllerTest {
@get:Rule val temporary = TemporaryFolder()
private lateinit var server: HttpServer
private lateinit var directory: File
private lateinit var base: String
private val apk = ByteArray(128 * 1024) { (it % 251).toByte() }
private var code = 100001L
private var releaseStatus = 200
private var assetStatus = 200
private var wireTruncated = false
private var metadataTruncated = false
private var listStatus = 200
private var listBody = "[]"
private var invalidJson = false
private var badHash = false
private var truncated = false
private var chunked = false
private var unsupported = false
private var redirect = false
private var redirectTarget: String? = null
private val requests = mutableListOf<Map<String, List<String>>>()
private val platform = Platform()
private inner class Platform : UpdatePlatform {
var archive = ApkIdentity(UpdateChannel.PACKAGE, InstalledVersion("1.1.1", 100001), setOf(UpdateChannel.SIGNER))
var installed = ApkIdentity(UpdateChannel.PACKAGE, InstalledVersion("1.1.0", 65), setOf(UpdateChannel.SIGNER))
var permission = true
var settingsAvailable = true
var installs = 0
var outcome = InstallOutcome.Cancelled
override fun installed() = installed
override fun inspect(file: File) = archive
override fun hasInstallPermission() = permission
override fun openPermissionSettings() = settingsAvailable
override fun install(file: File, outcome: (InstallOutcome) -> Unit) { installs++; outcome(this.outcome) }
}
private val preferences = object : UpdatePreferences {
override val autoCheck = MutableStateFlow(true)
override suspend fun url() = "$base/releases/latest"
override suspend fun enabled() = autoCheck.value
override suspend fun setAutoCheck(enabled: Boolean) { autoCheck.value = enabled }
}
@Before fun start() {
directory = temporary.newFolder()
server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
base = "http://127.0.0.1:${server.address.port}"
server.createContext("/") { exchange ->
synchronized(requests) { requests += exchange.requestHeaders.mapValues { it.value.toList() } }
val path = exchange.requestURI.path
val status = when (path) {
"/releases/latest" -> releaseStatus
"/releases" -> listStatus
"/apk" -> assetStatus
else -> 200
}
val content = when (path) {
"/releases/latest", "/redirected" -> if (invalidJson) "not json" else """{"name":"v1.1.1","body":"Household notes","published_at":"2026-11-07T12:00:00Z","assets":[{"name":"wolfskin-update.json","browser_download_url":"$base/manifest"},{"name":"Wolfskin-armeabi-v7a.apk","browser_download_url":"$base/apk"}]}"""
"/manifest" -> UpdateManifest.encode(manifest())
"/releases" -> listBody
else -> ""
}
if (redirect && path == "/releases/latest") {
exchange.responseHeaders.add("Location", redirectTarget ?: "$base/redirected")
exchange.responseHeaders.add("Set-Cookie", "private-session=never-forward")
exchange.sendResponseHeaders(302, -1)
} else {
val bytes = if (path == "/apk") (if (truncated) apk.copyOf(apk.size / 2) else apk) else content.toByteArray()
val length = when {
chunked && path == "/apk" -> 0L
wireTruncated && path == "/apk" -> apk.size.toLong()
metadataTruncated && path == "/releases/latest" -> bytes.size + 1L
else -> bytes.size.toLong()
}
exchange.sendResponseHeaders(status, length)
exchange.responseBody.use { it.write(bytes) }
}
exchange.close()
}
server.start()
}
@After fun stop() { server.stop(0) }
private fun manifest() = UpdateManifest(1, UpdateChannel.PACKAGE, "1.1.1", code,
"2026-11-07T12:00:00Z", "a".repeat(40), "pwolfskin-opt-v1.1.1", UpdateChannel.SIGNER,
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)))
private fun controller(scope: kotlinx.coroutines.CoroutineScope) = HouseholdUpdateController(scope, preferences, platform,
UpdateTransport(allowLocalTestHttp = true), VerifiedUpdateStore(directory, platform), listOf("armeabi-v7a"), 30)
@Test fun newerReleaseDownloadsVerifiesAndRestoresWithoutInstalling() = runTest {
val controller = controller(this)
controller.check(true); advanceUntilIdle()
assertTrue(controller.state.value is UpdateUiState.Available)
controller.download(); advanceUntilIdle()
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
assertEquals(0, platform.installs)
val restored = controller(this); advanceUntilIdle()
assertEquals(UpdateUiState.Ready("1.1.1"), restored.state.value)
restored.install(); advanceUntilIdle()
assertEquals(UpdateUiState.InstallCancelled, restored.state.value)
assertEquals(1, platform.installs)
assertTrue(File(directory, "verified.apk").exists())
}
@Test fun equalCodeIsUpToDateAndOlderCodeIsNotASuccess() = runTest {
code = 65
val equal = controller(this); equal.check(true); advanceUntilIdle()
assertEquals(UpdateUiState.UpToDate, equal.state.value)
code = 64
val older = controller(this); older.check(true); advanceUntilIdle()
assertEquals(UpdateFailure.LowerVersionCode, (older.state.value as UpdateUiState.Failed).kind)
}
@Test fun emptyChannelIsDistinctFromMissingOrRefusedEndpoint() = runTest {
releaseStatus = 404
val empty = controller(this); empty.check(true); advanceUntilIdle()
assertEquals(UpdateUiState.NoReleasePublished, empty.state.value)
for (status in listOf(401, 403, 404, 500, 503)) {
listStatus = status
val failure = controller(this); failure.check(true); advanceUntilIdle()
assertEquals(UpdateFailure.HttpRefused, (failure.state.value as UpdateUiState.Failed).kind)
}
}
@Test fun refusedLatestNeverBecomesUpToDate() = runTest {
for (status in listOf(401, 403, 500, 503)) {
releaseStatus = status
val controller = controller(this); controller.check(true); advanceUntilIdle()
assertEquals(UpdateFailure.HttpRefused, (controller.state.value as UpdateUiState.Failed).kind)
}
}
@Test fun invalidJsonAndUnavailableAbiAreTypedFailures() = runTest {
invalidJson = true
val invalid = controller(this); invalid.check(true); advanceUntilIdle()
assertEquals(UpdateFailure.InvalidMetadata, (invalid.state.value as UpdateUiState.Failed).kind)
invalidJson = false; unsupported = true
val unsupported = controller(this); unsupported.check(true); advanceUntilIdle()
assertEquals(UpdateFailure.UnsupportedAbi, (unsupported.state.value as UpdateUiState.Failed).kind)
}
@Test fun checksumAndLengthFailuresNeverCommitReady() = runTest {
for (badChecksum in listOf(true, false)) {
badHash = badChecksum; truncated = !badChecksum
val controller = controller(this); controller.check(true); advanceUntilIdle()
controller.download(); advanceUntilIdle()
assertEquals(UpdateFailure.ChecksumMismatch, (controller.state.value as UpdateUiState.Failed).kind)
assertFalse(File(directory, "ready.json").exists())
assertFalse(File(directory, "download.part").exists())
}
}
@Test fun wrongPackageSignerAndArchiveCodeCannotBecomeReady() = runTest {
val original = platform.archive
for ((archive, expected) in listOf(
original.copy(packageId = "other.package") to UpdateFailure.WrongPackage,
original.copy(signers = setOf("b".repeat(64))) to UpdateFailure.WrongSigner,
original.copy(version = InstalledVersion("1.1.1", 65)) to UpdateFailure.LowerVersionCode,
)) {
platform.archive = archive
val controller = controller(this); controller.check(true); advanceUntilIdle()
controller.download(); advanceUntilIdle()
assertEquals(expected, (controller.state.value as UpdateUiState.Failed).kind)
assertFalse(File(directory, "ready.json").exists())
}
}
@Test fun chunkedTransferReportsUnknownTotalAndCancelDeletesPartial() = runTest {
chunked = true
val controller = controller(this)
var observedUnknownLength = false
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
controller.state.collect { state ->
if (state is UpdateUiState.Downloading && state.bytesDone > 0) {
observedUnknownLength = state.bytesTotal == null
controller.cancel()
}
}
}
controller.check(true); advanceUntilIdle()
controller.download(); advanceUntilIdle()
assertTrue(observedUnknownLength)
assertTrue(controller.state.value is UpdateUiState.Available)
assertFalse(File(directory, "download.part").exists())
assertFalse(File(directory, "ready.json").exists())
}
@Test fun redirectRequestsContainNoCredentialsAndRejectUrlUserInfo() = runTest {
redirect = true
redirectTarget = "http://localhost:${server.address.port}/redirected"
val controller = controller(this); controller.check(true); advanceUntilIdle()
assertTrue(controller.state.value is UpdateUiState.Available)
assertTrue(requests.size >= 3)
requests.forEach { headers ->
assertFalse(headers.keys.any { it.equals("Authorization", true) || it.equals("Cookie", true) ||
it.contains("Jellyfin", true) || it.contains("Emby", true) || it.contains("MediaBrowser", true) })
}
redirectTarget = "http://user:password@127.0.0.1:${server.address.port}/redirected"
val rejected = controller(this); rejected.check(true); advanceUntilIdle()
assertEquals(UpdateFailure.InvalidMetadata, (rejected.state.value as UpdateUiState.Failed).kind)
}
@Test fun manualCheckWorksWithAutoOffAndPermissionDenialKeepsReady() = runTest {
preferences.autoCheck.value = false
val controller = controller(this)
controller.check(false); advanceUntilIdle()
assertEquals(UpdateUiState.Idle, controller.state.value)
controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
platform.permission = false
controller.install(); advanceUntilIdle()
assertEquals(UpdateUiState.PermissionNeeded, controller.state.value)
controller.check(true); advanceUntilIdle()
assertEquals(UpdateUiState.PermissionNeeded, controller.state.value)
controller.onResume(); advanceUntilIdle()
assertEquals(UpdateUiState.InstallCancelled, controller.state.value)
assertEquals(0, platform.installs)
platform.settingsAvailable = false
controller.install(); advanceUntilIdle()
assertEquals(UpdateUiState.NoInstaller, controller.state.value)
}
@Test fun corruptPersistedBytesAndUnverifiedPartialsAreDiscarded() = runTest {
val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
File(directory, "verified.apk").appendText("corrupt")
File(directory, "download.part").writeText("incomplete")
val recreated = controller(this); advanceUntilIdle()
assertEquals(UpdateUiState.Idle, recreated.state.value)
assertFalse(File(directory, "verified.apk").exists())
assertFalse(File(directory, "download.part").exists())
}
@Test fun downloadHttpFailureRetriesWithoutFalseSuccess() = runTest {
val controller = controller(this); controller.check(true); advanceUntilIdle()
assetStatus = 403
controller.download(); advanceUntilIdle()
assertEquals(UpdateFailure.HttpRefused, (controller.state.value as UpdateUiState.Failed).kind)
assetStatus = 200
controller.retry(); advanceUntilIdle()
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
}
@Test fun interruptedWireDownloadDoesNotPersistReady() = runTest {
truncated = true; wireTruncated = true
val controller = controller(this); controller.check(true); advanceUntilIdle()
controller.download(); advanceUntilIdle()
assertTrue(controller.state.value is UpdateUiState.Failed)
assertFalse(File(directory, "ready.json").exists())
assertFalse(File(directory, "download.part").exists())
}
@Test fun exhaustedStorageFailsBeforeDownloading() = runTest {
val fullDirectory = object : File(directory.absolutePath) { override fun getUsableSpace(): Long = 0 }
val controller = HouseholdUpdateController(this, preferences, platform, UpdateTransport(true),
VerifiedUpdateStore(fullDirectory, platform), listOf("armeabi-v7a"), 30)
controller.check(true); advanceUntilIdle()
val before = requests.size
controller.download(); advanceUntilIdle()
assertEquals(UpdateFailure.InsufficientStorage, (controller.state.value as UpdateUiState.Failed).kind)
assertEquals(before, requests.size)
}
@Test fun successCallbackRefreshesPackageManagerInsteadOfClaimingAnUpgrade() = runTest {
val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
platform.outcome = InstallOutcome.Success
controller.install(); advanceUntilIdle()
assertEquals(65L, controller.installedVersion.value.versionCode)
assertTrue(File(directory, "verified.apk").exists())
platform.installed = platform.archive
controller.onResume(); advanceUntilIdle()
assertEquals(100001L, controller.installedVersion.value.versionCode)
assertEquals(UpdateUiState.Idle, controller.state.value)
assertFalse(File(directory, "verified.apk").exists())
}
@Test fun successCallbackDeliveredToTheReplacedProcessDoesNotLeaveAFalseHandoff() = runTest {
val old = controller(this); old.check(true); advanceUntilIdle(); old.download(); advanceUntilIdle()
platform.installed = platform.archive
val replaced = controller(this); advanceUntilIdle()
replaced.handleInstallOutcome(InstallOutcome.Success); advanceUntilIdle()
assertEquals(100001L, replaced.installedVersion.value.versionCode)
assertEquals(UpdateUiState.Idle, replaced.state.value)
replaced.handleInstallOutcome(InstallOutcome.Cancelled); advanceUntilIdle()
assertEquals(UpdateUiState.Idle, replaced.state.value)
assertFalse(File(directory, "verified.apk").exists())
}
@Test fun reentryCheckCannotReplacePendingConfirmationOrStartAnotherSession() = runTest {
val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
platform.outcome = InstallOutcome.Pending
controller.install(); advanceUntilIdle()
val before = requests.size
controller.check(true); advanceUntilIdle()
assertEquals(UpdateUiState.HandingOff, controller.state.value)
controller.install(); advanceUntilIdle()
assertEquals(1, platform.installs)
assertEquals(before, requests.size)
controller.handleInstallOutcome(InstallOutcome.Cancelled); advanceUntilIdle()
assertEquals(UpdateUiState.InstallCancelled, controller.state.value)
assertTrue(File(directory, "verified.apk").exists())
}
@Test fun productionTransportRefusesCleartextBeforeRequesting() = runTest {
try {
UpdateTransport().latest("$base/releases/latest", listOf("armeabi-v7a"), 30)
fail("Cleartext should be refused")
} catch (error: UpdateException) { assertEquals(UpdateFailure.InvalidMetadata, error.kind) }
assertTrue(requests.isEmpty())
}
@Test fun unavailableNetworkIsOfflineNotUpToDate() = runTest {
server.stop(0)
val controller = controller(this); controller.check(true); advanceUntilIdle()
assertEquals(UpdateUiState.Offline, controller.state.value)
}
@Test fun interruptedFirstCheckIsNetworkFailureNotMissingDownloadStorage() = runTest {
directory = File(directory, "not-created")
metadataTruncated = true
val controller = controller(this); controller.check(true); advanceUntilIdle()
assertEquals(UpdateFailure.Network, (controller.state.value as UpdateUiState.Failed).kind)
assertFalse(directory.exists())
}
@Test fun compatibleArm32IsPreferredAndUniversalRespectsMinimumSdk() {
val arm32 = manifest().assets.single()
val arm64 = arm32.copy(abi = "arm64-v8a", name = "Wolfskin-arm64-v8a.apk")
val universal = arm32.copy(abi = "universal", name = "Wolfskin.apk", minSdk = 31)
val manifest = manifest().copy(assets = listOf(arm64, arm32, universal))
assertEquals(arm32, manifest.selectAsset(listOf("arm64-v8a", "armeabi-v7a"), 30))
try { manifest.selectAsset(listOf("unknown"), 30); fail("Minimum SDK must be enforced") }
catch (error: UpdateException) { assertEquals(UpdateFailure.UnsupportedAbi, error.kind) }
}
}

View file

@ -0,0 +1,49 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv.update
import org.junit.Assert.assertEquals
import org.junit.Assert.fail
import org.junit.Test
/** Malformed channel metadata must never become a compatible version comparison. */
class UpdateManifestTest {
private val asset = UpdateAsset("armeabi-v7a", "Wolfskin-armeabi-v7a.apk", 128, "a".repeat(64), 23)
private val manifest = UpdateManifest(1, UpdateChannel.PACKAGE, "1.1.1", 100001,
"2026-11-07T12:00:00Z", "b".repeat(40), "pwolfskin-opt-v1.1.1", UpdateChannel.SIGNER, listOf(asset))
@Test fun malformedManifestFieldsAreRejectedBeforeVersionComparison() {
val invalid = listOf(
manifest.copy(schemaVersion = 2),
manifest.copy(versionName = "1.1.1-opt"),
manifest.copy(versionCode = 0),
manifest.copy(publishedAt = "yesterday"),
manifest.copy(sourceCommit = "main"),
manifest.copy(sourceTag = ""),
manifest.copy(signerSha256 = "not-a-certificate-fingerprint"),
manifest.copy(assets = emptyList()),
manifest.copy(assets = listOf(asset.copy(bytes = 0))),
manifest.copy(assets = listOf(asset.copy(sha256 = "not-a-checksum"))),
manifest.copy(assets = listOf(asset.copy(abi = "arm64-v8a"))),
manifest.copy(assets = listOf(asset, asset)),
)
invalid.forEach { candidate ->
try { UpdateManifest.parse(UpdateManifest.encode(candidate)); fail("Invalid manifest was accepted: $candidate") }
catch (error: UpdateException) { assertEquals(UpdateFailure.InvalidMetadata, error.kind) }
}
}
@Test fun fallbackRequiresACompatibleStandaloneAsset() {
val universal = asset.copy(abi = "universal", name = "Wolfskin.apk", minSdk = 35)
val parsed = UpdateManifest.parse(UpdateManifest.encode(manifest.copy(assets = listOf(universal))))
assertEquals(universal, parsed.selectAsset(listOf("arm64-v8a"), 35))
try { parsed.selectAsset(listOf("arm64-v8a"), 34); fail("Unsupported minimum SDK was accepted") }
catch (error: UpdateException) { assertEquals(UpdateFailure.UnsupportedAbi, error.kind) }
}
@Test fun universalDoesNotMakeAnUnsupportedProcessorCompatible() {
val universal = asset.copy(abi = "universal", name = "Wolfskin.apk")
val parsed = UpdateManifest.parse(UpdateManifest.encode(manifest.copy(assets = listOf(universal))))
try { parsed.selectAsset(listOf("riscv64"), 34); fail("Unsupported processor was accepted") }
catch (error: UpdateException) { assertEquals(UpdateFailure.UnsupportedAbi, error.kind) }
}
}