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

@ -56,6 +56,10 @@ val gitDescribe =
.standardOutput.asText
.getOrElse("v0.0.0")
val tvVersion = providers.gradleProperty("tvVersion").orElse("1.1.1")
val tvVersionCode = providers.gradleProperty("tvVersionCode")
apply(from = "household-version.gradle.kts")
kotlin {
compilerOptions {
languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3
@ -228,6 +232,13 @@ configure<ApplicationExtension> {
androidComponents {
onVariants(selector().all()) { variant ->
if (variant.name == "defaultOptimized") {
variant.outputs.forEach {
it.versionName.set(tvVersion)
// The sentinel is configuration-only: optimized tasks require the explicit input above.
it.versionCode.set(tvVersionCode.map(String::toInt).orElse(1))
}
}
variant.outputs
.map { it as com.android.build.api.variant.impl.VariantOutputImpl }
.forEach { output ->

View file

@ -0,0 +1,18 @@
// Household assembly inputs remain mandatory even through aggregate Gradle tasks.
val tvVersion = providers.gradleProperty("tvVersion").orElse("1.1.1")
val tvVersionCode = providers.gradleProperty("tvVersionCode")
fun requireHouseholdVersion() {
require(tvVersionCode.isPresent) { "Household builds require -PtvVersionCode=<monotonic code>" }
require(tvVersionCode.get().toInt() in 1..2100000000) { "Invalid household versionCode" }
require(Regex("""\d+\.\d+\.\d+""").matches(tvVersion.get())) { "Household versionName must be plain major.minor.patch" }
}
if (gradle.startParameter.taskNames.any { it.contains("Optimized", ignoreCase = true) }) {
requireHouseholdVersion()
}
tasks.configureEach {
if (name.contains("DefaultOptimized", ignoreCase = true)) {
doFirst { requireHouseholdVersion() }
}
}

View file

@ -36,6 +36,13 @@
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="video/*" />
</intent>
<intent>
<action android:name="android.intent.action.INSTALL_PACKAGE" />
<data android:mimeType="application/vnd.android.package-archive" />
</intent>
<intent>
<action android:name="android.settings.MANAGE_UNKNOWN_APP_SOURCES" />
</intent>
</queries>
<application
@ -70,6 +77,13 @@
<data android:scheme="wholphin" />
</intent-filter>
</activity>
<receiver
android:name="org.justinvisser.wolfskin.tv.update.UpdateInstallReceiver"
android:exported="false" />
<activity
android:name="org.justinvisser.wolfskin.tv.update.UpdateConfirmationActivity"
android:exported="false"
android:excludeFromRecents="true" />
<provider
android:name="androidx.core.content.FileProvider"

View file

@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
import com.github.damontecres.wholphin.ui.preferences.PreferenceValidation
import com.github.damontecres.wholphin.util.DebugLogTree
import org.justinvisser.wolfskin.tv.update.UpdateChannel
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
@ -627,7 +628,7 @@ sealed interface AppPreference<Pref, T> {
val UpdateUrl =
AppStringPreference<AppPreferences>(
title = R.string.update_url,
defaultValue = "https://git.justinvisser.org/api/v1/repos/justinzeus/wolfskin/releases/latest",
defaultValue = UpdateChannel.defaultUrl(BuildConfig.APPLICATION_ID),
getter = { it.updateUrl },
setter = { prefs, value ->
prefs.update { updateUrl = value }

View file

@ -36,6 +36,9 @@ import kotlinx.serialization.json.jsonPrimitive
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.justinvisser.wolfskin.tv.update.AndroidUpdateService
import org.justinvisser.wolfskin.tv.update.UpdateChannel
import org.justinvisser.wolfskin.tv.update.UpdateTransport
import timber.log.Timber
import java.io.File
import java.io.InputStream
@ -43,6 +46,7 @@ import java.io.OutputStream
import java.util.Date
import javax.inject.Inject
import javax.inject.Singleton
import javax.inject.Provider
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
@ -55,6 +59,7 @@ class UpdateChecker
constructor(
@param:ApplicationContext private val context: Context,
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
private val household: Provider<AndroidUpdateService>,
) {
companion object {
const val ASSET_NAME = "Wolfskin"
@ -123,6 +128,10 @@ class UpdateChecker
updateUrl: String,
showNegativeToast: Boolean = false,
) {
if (context.packageName == UpdateChannel.PACKAGE) {
household.get().controller.check(manual = showNegativeToast)
return
}
val pref = PreferenceManager.getDefaultSharedPreferences(context)
val now = Date()
val lastUpdateCheckThreshold =
@ -181,6 +190,12 @@ class UpdateChecker
* so this searches the release list instead of fetching a tag.
*/
suspend fun getRelease(version: Version): Release? {
if (context.packageName == UpdateChannel.PACKAGE) {
return withContext(WholphinDispatchers.IO) {
val releases = UpdateTransport().releasesForLatest(household.get().channelUrl())
findReleaseByVersion(releases, version)?.let { parseRelease(it, false) }
}
}
val request =
Request
.Builder()
@ -233,6 +248,9 @@ class UpdateChecker
release: Release,
callback: DownloadCallback,
) {
check(context.packageName != UpdateChannel.PACKAGE) {
"Household updates require the verified update controller"
}
withContext(WholphinDispatchers.IO) {
cleanup()
val request =

View file

@ -47,6 +47,7 @@ import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import coil3.SingletonImageLoader
import coil3.imageLoader
import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
@ -89,6 +90,7 @@ import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch
import org.justinvisser.wolfskin.tv.update.UpdateChannel
import timber.log.Timber
import java.util.Locale
@ -355,7 +357,9 @@ fun PreferencesContent(
AppPreference.Update -> {
ClickPreference(
title =
if (release != null && updateAvailable) {
if (BuildConfig.APPLICATION_ID == UpdateChannel.PACKAGE) {
stringResource(R.string.check_for_updates)
} else if (release != null && updateAvailable) {
stringResource(R.string.install_update)
} else if (!preferences.autoCheckForUpdates && release == null) {
stringResource(R.string.check_for_updates)
@ -364,7 +368,9 @@ fun PreferencesContent(
},
onClick = {
if (movementSounds) playOnClickSound(context)
if (release != null && updateAvailable) {
if (BuildConfig.APPLICATION_ID == UpdateChannel.PACKAGE) {
viewModel.navigationManager.navigateTo(Destination.UpdateApp)
} else if (release != null && updateAvailable) {
release?.let {
viewModel.navigationManager.navigateTo(
Destination.UpdateApp,

View file

@ -44,6 +44,7 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.DownloadCallback
import com.github.damontecres.wholphin.services.NavigationManager
@ -73,6 +74,9 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
import org.justinvisser.wolfskin.tv.update.ClassicUpdatePage
import org.justinvisser.wolfskin.tv.update.UpdateChannel
import org.justinvisser.wolfskin.tv.update.UpdateController
import javax.inject.Inject
@HiltViewModel
@ -82,6 +86,7 @@ class UpdateViewModel
private val userPreferencesService: UserPreferencesService,
val updater: UpdateChecker,
val navigationManager: NavigationManager,
val householdController: UpdateController,
) : ViewModel(),
DownloadCallback {
private val _state = MutableStateFlow(InstallUpdateState())
@ -93,6 +98,10 @@ class UpdateViewModel
val currentVersion = updater.getInstalledVersion()
fun init() {
if (BuildConfig.APPLICATION_ID == UpdateChannel.PACKAGE) {
householdController.check(true)
return
}
_state.update { it.copy(loading = LoadingState.Loading) }
viewModelScope.launchIO {
val updateUrl = userPreferencesService.getCurrent().appPreferences.updateUrl
@ -166,6 +175,10 @@ fun InstallUpdatePage(
modifier: Modifier = Modifier,
viewModel: UpdateViewModel = hiltViewModel(),
) {
if (BuildConfig.APPLICATION_ID == UpdateChannel.PACKAGE) {
ClassicUpdatePage(viewModel.householdController, modifier)
return
}
OneTimeLaunchedEffect { viewModel.init() }
val state by viewModel.state.collectAsState()

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) }
}
}

View file

@ -0,0 +1,90 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv.update
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInfo
import android.content.pm.PackageInstaller
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import java.security.MessageDigest
import javax.inject.Inject
import javax.inject.Singleton
/** PackageManager inspection and confirmation-required PackageInstaller sessions. */
@Singleton
class AndroidUpdatePlatform @Inject constructor(@param:ApplicationContext private val context: Context) : UpdatePlatform {
private val manager = context.packageManager
private val sessions = context.getSharedPreferences("update-install-session", Context.MODE_PRIVATE)
override fun installed(): ApkIdentity = identity(manager.getPackageInfo(context.packageName, signatureFlags()))
override fun inspect(file: File): ApkIdentity? = manager.getPackageArchiveInfo(file.absolutePath, signatureFlags())?.let(::identity)
@Suppress("DEPRECATION")
private fun signatureFlags(): Int = if (Build.VERSION.SDK_INT >= 28) PackageManager.GET_SIGNING_CERTIFICATES else PackageManager.GET_SIGNATURES
@Suppress("DEPRECATION")
private fun identity(info: PackageInfo): ApkIdentity {
val certificates = if (Build.VERSION.SDK_INT >= 28) info.signingInfo?.apkContentsSigners else info.signatures
val hashes = certificates.orEmpty().map {
MessageDigest.getInstance("SHA-256").digest(it.toByteArray()).toHexString()
}.toSet()
val code = if (Build.VERSION.SDK_INT >= 28) info.longVersionCode else info.versionCode.toLong()
return ApkIdentity(info.packageName, InstalledVersion(info.versionName.orEmpty(), code), hashes)
}
override fun hasInstallPermission(): Boolean = if (Build.VERSION.SDK_INT >= 26) manager.canRequestPackageInstalls()
else Settings.Secure.getInt(context.contentResolver, Settings.Secure.INSTALL_NON_MARKET_APPS, 0) == 1
override fun openPermissionSettings(): Boolean {
val intent = if (Build.VERSION.SDK_INT >= 26) Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}"))
else Intent(Settings.ACTION_SECURITY_SETTINGS)
if (intent.resolveActivity(manager) == null) return false
return try { context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); true } catch (_: RuntimeException) { false }
}
override fun install(file: File, outcome: (InstallOutcome) -> Unit) {
if (context.noBackupFilesDir.usableSpace < file.length()) {
outcome(InstallOutcome.InsufficientStorage)
return
}
val installer = manager.packageInstaller
val parameters = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
setAppPackageName(context.packageName)
setSize(file.length())
if (Build.VERSION.SDK_INT >= 31) setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_REQUIRED)
}
var sessionId: Int? = null
try {
sessionId = installer.createSession(parameters)
check(sessions.edit().putInt("id", sessionId).commit())
installer.openSession(sessionId).use { session ->
session.openWrite("Wolfskin.apk", 0, file.length()).use { output ->
file.inputStream().use { it.copyTo(output) }
session.fsync(output)
}
val intent = Intent(context, UpdateInstallReceiver::class.java).setAction("${context.packageName}.UPDATE_RESULT")
val flags = PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_MUTABLE else 0
val result = PendingIntent.getBroadcast(context, sessionId, intent, flags)
session.commit(result.intentSender)
}
} catch (_: Exception) {
sessionId?.let { runCatching { installer.abandonSession(it) } }
outcome(InstallOutcome.Unavailable)
}
}
fun ownsSession(intent: Intent): Boolean = intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, -1) == sessions.getInt("id", -2)
fun launchConfirmation(intent: Intent): Boolean {
val activity = Intent(context, UpdateConfirmationActivity::class.java).putExtra("confirmation", intent)
return try {
context.startActivity(activity.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
true
} catch (_: RuntimeException) { false }
}
}

View file

@ -0,0 +1,58 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv.update
import android.content.Context
import android.os.Build
import androidx.datastore.core.DataStore
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.update
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
/** Hilt entry point shared by app startup, Wolfskin UI and classic update controls. */
@Singleton
class AndroidUpdateService @Inject constructor(
@param:ApplicationContext context: Context,
@param:IoCoroutineScope scope: CoroutineScope,
dataStore: DataStore<AppPreferences>,
val platform: AndroidUpdatePlatform,
) {
private val preferences = object : UpdatePreferences {
override val autoCheck = dataStore.data.map { it.autoCheckForUpdates }.stateIn(scope, SharingStarted.Eagerly, true)
override suspend fun enabled() = dataStore.data.first().autoCheckForUpdates
override suspend fun url(): String {
val prefs = dataStore.updateData { current ->
val url = UpdateChannel.migrateUrl(context.packageName, current.updateUrl)
if (url == current.updateUrl) current else current.update { updateUrl = url }
}
return prefs.updateUrl
}
override suspend fun setAutoCheck(enabled: Boolean) {
dataStore.updateData { it.update { autoCheckForUpdates = enabled } }
}
}
val controller = HouseholdUpdateController(scope, preferences, platform, UpdateTransport(),
VerifiedUpdateStore(File(context.noBackupFilesDir, "updates"), platform), Build.SUPPORTED_ABIS.toList(), Build.VERSION.SDK_INT)
suspend fun channelUrl(): String = preferences.url()
}
/** Single process-scoped controller binding for every update entry point. */
@Module
@InstallIn(SingletonComponent::class)
object UpdateModule {
@Provides
@Singleton
fun provideUpdateController(service: AndroidUpdateService): UpdateController = service.controller
}

View file

@ -0,0 +1,82 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv.update
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.tv.material3.Button
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.ui.components.ScrollableDialog
import com.mikepenz.markdown.m3.Markdown
import org.justinvisser.wolfskin.design.WolfskinTokens
/** Trial entry point uses the same verified service, never the legacy download-and-install path. */
@Composable
fun ClassicUpdatePage(controller: UpdateController, modifier: Modifier = Modifier) {
val state by controller.state.collectAsState()
val installed by controller.installedVersion.collectAsState()
val lifecycle = LocalLifecycleOwner.current.lifecycle
var notes by remember { mutableStateOf<String?>(null) }
LaunchedEffect(controller) { controller.check(true) }
DisposableEffect(lifecycle, controller) {
val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) controller.onResume() }
lifecycle.addObserver(observer)
onDispose { lifecycle.removeObserver(observer) }
}
Column(modifier, verticalArrangement = Arrangement.spacedBy(WolfskinTokens.Shell.settingsRowGap)) {
Text("Installed: ${installed.versionName}")
when (val current = state) {
is UpdateUiState.Available -> {
Text("${current.versionName} · ${current.bytes} bytes · ${current.dateText}")
Button(onClick = controller::download) { Text("Download") }
if (current.notes.isNotBlank()) {
Button(onClick = { notes = current.notes }) { Text("Release notes") }
}
}
is UpdateUiState.Downloading -> {
Text("${current.bytesDone} / ${current.bytesTotal ?: "—"} bytes")
Button(onClick = controller::cancel) { Text("Cancel download") }
}
is UpdateUiState.Ready, UpdateUiState.InstallCancelled, UpdateUiState.PermissionNeeded, UpdateUiState.NoInstaller -> {
Text(when (current) {
UpdateUiState.InstallCancelled -> "Installation cancelled; verified download retained"
UpdateUiState.PermissionNeeded -> "Allow updates from this app in Android settings"
UpdateUiState.NoInstaller -> "Android installer or permission settings unavailable"
else -> "Verified update ready"
})
Button(onClick = controller::install) { Text("Install update") }
}
is UpdateUiState.Failed -> {
Text(current.message)
Button(onClick = controller::retry) { Text("Retry") }
}
UpdateUiState.HandingOff -> Text("Confirm installation in Android")
else -> {
Text(when (current) {
UpdateUiState.UpToDate -> "Up to date"
UpdateUiState.NoReleasePublished -> "No release published"
UpdateUiState.Offline -> "No network"
UpdateUiState.Checking -> "Checking"
else -> "Check for an update"
})
Button(onClick = { controller.check(true) }) { Text("Check again") }
}
}
}
notes?.let { body ->
ScrollableDialog(onDismissRequest = { notes = null }) {
item { Markdown(content = body) }
}
}
}

View file

@ -0,0 +1,197 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv.update
import java.io.IOException
import java.net.ConnectException
import java.net.UnknownHostException
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/** Existing app preferences, not a separate updater preference store. */
interface UpdatePreferences {
val autoCheck: StateFlow<Boolean>
suspend fun url(): String
suspend fun enabled(): Boolean
suspend fun setAutoCheck(enabled: Boolean)
}
/** Process-owned operations outlive screens; only explicit install() crosses into Android. */
class HouseholdUpdateController(
private val scope: CoroutineScope,
private val preferences: UpdatePreferences,
private val platform: UpdatePlatform,
private val transport: UpdateTransport,
private val store: VerifiedUpdateStore,
private val abis: List<String>,
private val sdk: Int,
) : UpdateController {
private val mutableState = MutableStateFlow<UpdateUiState>(UpdateUiState.Idle)
private val mutableVersion = MutableStateFlow(platform.installed().version)
override val state = mutableState.asStateFlow()
override val installedVersion = mutableVersion.asStateFlow()
override val autoCheck = preferences.autoCheck
private var candidate: UpdateCandidate? = null
private var ready: UpdateManifest? = null
private var operation: Job? = null
private var retryOperation: () -> Unit = { check(true) }
private val restored = scope.async {
ready = store.restore(abis, sdk)
ready?.let { mutableState.value = UpdateUiState.Ready(it.versionName) }
}
@Synchronized
private fun runOperation(action: suspend () -> Unit): Unit {
if (operation?.isActive == true) return
operation = scope.launch {
restored.await()
try { action() }
catch (error: CancellationException) { throw error }
catch (error: UpdateException) { mutableState.value = UpdateUiState.Failed(error.kind, error.message) }
catch (_: UnknownHostException) { mutableState.value = UpdateUiState.Offline }
catch (_: ConnectException) { mutableState.value = UpdateUiState.Offline }
catch (error: IOException) {
val release = candidate
val full = state.value is UpdateUiState.Downloading && release != null &&
store.directory.usableSpace < release.asset.bytes
mutableState.value = UpdateUiState.Failed(if (full) UpdateFailure.InsufficientStorage else UpdateFailure.Network,
if (full) "Not enough storage for the update" else "Update transfer failed; try again")
}
catch (_: Exception) { mutableState.value = UpdateUiState.Failed(UpdateFailure.InvalidMetadata, "Update could not be processed") }
}
}
private fun systemOwnsFlow(current: UpdateUiState): Boolean =
current == UpdateUiState.HandingOff || current == UpdateUiState.PermissionNeeded
override fun check(manual: Boolean): Unit = runOperation {
if (systemOwnsFlow(state.value)) return@runOperation
if (!manual && !preferences.enabled()) return@runOperation
ready?.let { manifest ->
mutableState.update { if (systemOwnsFlow(it)) it else UpdateUiState.Ready(manifest.versionName) }
return@runOperation
}
retryOperation = { check(true) }
mutableState.value = UpdateUiState.Checking
val release = transport.latest(preferences.url(), abis, sdk)
if (release == null) { mutableState.value = UpdateUiState.NoReleasePublished; return@runOperation }
val installed = platform.installed()
mutableVersion.value = installed.version
if (release.manifest.packageId != installed.packageId) throw UpdateException(UpdateFailure.WrongPackage, "Release belongs to a different application")
if (installed.signers != setOf(release.manifest.signerSha256)) throw UpdateException(UpdateFailure.WrongSigner, "Release signer differs from this installation")
if (release.manifest.versionCode < installed.version.versionCode) throw UpdateException(UpdateFailure.LowerVersionCode, "Channel release is older than this installation")
if (release.manifest.versionCode == installed.version.versionCode) {
candidate = null
mutableState.value = UpdateUiState.UpToDate
} else { candidate = release; mutableState.value = available(release) }
}
private fun available(release: UpdateCandidate): UpdateUiState.Available {
val date = DateTimeFormatter.ofLocalizedDate(java.time.format.FormatStyle.MEDIUM).withLocale(Locale.getDefault())
.withZone(ZoneId.systemDefault()).format(Instant.parse(release.manifest.publishedAt))
return UpdateUiState.Available(release.manifest.versionName, release.asset.bytes, date, release.notes)
}
override fun download(): Unit = runOperation {
if (systemOwnsFlow(state.value)) return@runOperation
if (ready != null) return@runOperation
val release = candidate ?: return@runOperation
retryOperation = { download() }
store.prepare()
if (store.directory.usableSpace < release.asset.bytes) throw UpdateException(UpdateFailure.InsufficientStorage, "Not enough storage for the update")
mutableState.value = UpdateUiState.Downloading(0, null)
try {
transport.download(release, store.partial) { done, total -> mutableState.value = UpdateUiState.Downloading(done, total) }
currentCoroutineContext().ensureActive()
store.commit(release.manifest, release.asset)
currentCoroutineContext().ensureActive()
ready = release.manifest
mutableState.value = UpdateUiState.Ready(release.manifest.versionName)
} catch (error: Exception) {
store.clear()
throw error
} finally { store.partial.delete() }
}
@Synchronized
override fun cancel() {
if (systemOwnsFlow(state.value)) return
transport.cancel()
val previous = operation
previous?.cancel()
operation = scope.launch {
previous?.join()
store.partial.delete()
mutableState.value = ready?.let { UpdateUiState.Ready(it.versionName) }
?: candidate?.let { available(it) } ?: UpdateUiState.Idle
}
}
override fun install(): Unit = runOperation {
if (state.value == UpdateUiState.HandingOff) return@runOperation
val manifest = ready ?: return@runOperation
retryOperation = { install() }
try { store.verify(store.apk, manifest, manifest.selectAsset(abis, sdk)) } catch (error: Exception) {
store.clear(); ready = null
retryOperation = { if (candidate != null) download() else check(true) }
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) {
scope.launch {
restored.await()
if (ready == null && outcome != InstallOutcome.Pending) {
retryOperation = { check(true) }
mutableState.value = UpdateUiState.Idle
onResume()
return@launch
}
mutableState.value = when (outcome) {
InstallOutcome.Pending -> UpdateUiState.HandingOff
InstallOutcome.Cancelled -> UpdateUiState.InstallCancelled
InstallOutcome.Unavailable -> UpdateUiState.NoInstaller
InstallOutcome.InsufficientStorage -> UpdateUiState.Failed(UpdateFailure.InsufficientStorage, "Android needs more storage to install the retained update")
InstallOutcome.Failed -> UpdateUiState.Failed(UpdateFailure.InvalidMetadata, "Android could not install the update; the verified download is retained")
InstallOutcome.Success -> UpdateUiState.HandingOff
}
if (outcome != InstallOutcome.Pending) onResume()
}
}
override fun retry() { retryOperation() }
override fun setAutoCheck(enabled: Boolean) { scope.launch { preferences.setAutoCheck(enabled) } }
override fun onResume() {
scope.launch {
operation?.join()
restored.await()
mutableVersion.value = platform.installed().version
val manifest = ready ?: return@launch
if (mutableVersion.value.versionCode >= manifest.versionCode) {
store.clear(); ready = null; candidate = null; mutableState.value = UpdateUiState.Idle
} else if (state.value == UpdateUiState.PermissionNeeded) {
// Returning from settings requires another explicit Install press.
mutableState.value = if (platform.hasInstallPermission()) UpdateUiState.Ready(manifest.versionName)
else UpdateUiState.InstallCancelled
}
}
}
}

View file

@ -0,0 +1,14 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv.update
/** Package-scoped channel policy; the old production sandbox retains its original feed. */
object UpdateChannel {
const val PACKAGE = "org.justinvisser.wolfskin.tv.opt"
const val SIGNER = "182bac72e292b9ea4d3d4e6383f92eb2161fa28ba5afcff0337cb7d5a9d5341d"
const val LEGACY_URL = "https://git.justinvisser.org/api/v1/repos/justinzeus/wolfskin/releases/latest"
const val LATEST_URL = "https://git.justinvisser.org/api/v1/repos/justinzeus/wolfskin-tv-updates/releases/latest"
fun defaultUrl(packageId: String): String = if (packageId == PACKAGE) LATEST_URL else LEGACY_URL
fun migrateUrl(packageId: String, saved: String): String =
if (packageId == PACKAGE && (saved == LEGACY_URL || saved.isBlank())) LATEST_URL else saved
fun isNewer(candidateCode: Long, installedCode: Long): Boolean = candidateCode > installedCode
}

View file

@ -0,0 +1,61 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv.update
import androidx.activity.ComponentActivity
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.os.Bundle
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
/** Explicit, non-exported callback target accepts only the locally recorded install session. */
@AndroidEntryPoint
class UpdateInstallReceiver : BroadcastReceiver() {
@Inject lateinit var service: AndroidUpdateService
@Suppress("DEPRECATION")
override fun onReceive(context: Context, intent: Intent) {
if (!service.platform.ownsSession(intent)) return
val outcome = when (intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
val confirmation = intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)
if (confirmation != null && service.platform.launchConfirmation(confirmation)) InstallOutcome.Pending else InstallOutcome.Unavailable
}
PackageInstaller.STATUS_SUCCESS -> InstallOutcome.Success
PackageInstaller.STATUS_FAILURE_ABORTED -> InstallOutcome.Cancelled
PackageInstaller.STATUS_FAILURE_STORAGE -> InstallOutcome.InsufficientStorage
else -> InstallOutcome.Failed
}
service.controller.handleInstallOutcome(outcome)
}
}
/** System confirmation is launched for result so cancelling retains the already-verified APK. */
@AndroidEntryPoint
class UpdateConfirmationActivity : ComponentActivity() {
@Inject lateinit var service: AndroidUpdateService
@Suppress("DEPRECATION")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState != null) return
val confirmation = intent.getParcelableExtra<Intent>("confirmation")
if (confirmation == null || confirmation.resolveActivity(packageManager) == null) {
service.controller.handleInstallOutcome(InstallOutcome.Unavailable)
finish()
return
}
try { startActivityForResult(confirmation, 1) } catch (_: RuntimeException) {
service.controller.handleInstallOutcome(InstallOutcome.Unavailable)
finish()
}
}
@Deprecated("System installer uses the activity result contract")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == 1 && resultCode == RESULT_CANCELED) service.controller.handleInstallOutcome(InstallOutcome.Cancelled)
service.controller.onResume()
finish()
}
}

View file

@ -0,0 +1,76 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv.update
import java.time.Instant
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
/** Signed APK metadata published only after the final APKs have been inspected. */
@Serializable
data class UpdateManifest(
val schemaVersion: Int,
val packageId: String,
val versionName: String,
val versionCode: Long,
val publishedAt: String,
val sourceCommit: String,
val sourceTag: String,
val signerSha256: String,
val assets: List<UpdateAsset>,
) {
fun selectAsset(abis: List<String>, sdk: Int): UpdateAsset {
fun compatible(abi: String) = assets.firstOrNull { it.abi == abi && it.minSdk <= sdk }
if ("armeabi-v7a" in abis) compatible("armeabi-v7a")?.let { return it }
for (abi in abis) {
if (abi != "armeabi-v7a" && abi != "universal") compatible(abi)?.let { return it }
}
if (abis.any { it != "universal" && it in supportedAbis }) {
compatible("universal")?.let { return it }
}
throw UpdateException(UpdateFailure.UnsupportedAbi, "No compatible APK for this device")
}
companion object {
private val json = Json { ignoreUnknownKeys = true }
private val versionPattern = Regex("[0-9]+\\.[0-9]+\\.[0-9]+")
private val commitPattern = Regex("[0-9a-f]{40}")
private val hashPattern = Regex("[0-9a-f]{64}")
private val supportedAbis = setOf("armeabi-v7a", "arm64-v8a", "x86_64", "universal")
fun parse(text: String): UpdateManifest {
try {
val manifest = json.decodeFromString<UpdateManifest>(text)
require(manifest.schemaVersion == 1)
require(manifest.packageId.isNotBlank())
require(versionPattern.matches(manifest.versionName))
require(manifest.versionCode in 1..2100000000)
Instant.parse(manifest.publishedAt)
require(commitPattern.matches(manifest.sourceCommit))
require(manifest.sourceTag.isNotBlank())
require(isHash(manifest.signerSha256))
require(manifest.assets.isNotEmpty())
require(manifest.assets.map { it.abi }.distinct().size == manifest.assets.size)
require(manifest.assets.map { it.name }.distinct().size == manifest.assets.size)
manifest.assets.forEach {
require(it.abi in supportedAbis)
require(it.name == if (it.abi == "universal") "Wolfskin.apk" else "Wolfskin-${it.abi}.apk")
require(it.bytes > 0 && it.minSdk > 0 && isHash(it.sha256))
}
return manifest
} catch (error: Exception) {
throw UpdateException(UpdateFailure.InvalidMetadata, "Invalid update manifest", error)
}
}
private fun isHash(value: String) = hashPattern.matches(value)
fun encode(manifest: UpdateManifest): String = json.encodeToString(manifest)
}
}
/** A standalone APK, not a split installation dependency. */
@Serializable
data class UpdateAsset(val abi: String, val name: String, val bytes: Long, val sha256: String, val minSdk: Int)
/** Selected immutable release metadata and its anonymous asset URL. */
data class UpdateCandidate(val manifest: UpdateManifest, val asset: UpdateAsset, val url: String, val notes: String)
/** Typed boundary error, never interpreted as an up-to-date result. */
class UpdateException(val kind: UpdateFailure, override val message: String, cause: Throwable? = null) : Exception(message, cause)

View file

@ -0,0 +1,149 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv.update
import com.github.damontecres.wholphin.BuildConfig
import java.io.File
import java.util.concurrent.atomic.AtomicReference
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resumeWithException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Call
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
/** Dedicated unauthenticated client; redirects are individually validated before any request. */
class UpdateTransport(private val allowLocalTestHttp: Boolean = false) {
private val client = OkHttpClient.Builder().followRedirects(false).followSslRedirects(false).build()
private val activeCall = AtomicReference<Call?>()
fun cancel() { activeCall.get()?.cancel() }
private fun validateUrl(value: String): okhttp3.HttpUrl {
val url = value.toHttpUrlOrNull()
?: throw UpdateException(UpdateFailure.InvalidMetadata, "Invalid update URL")
val fixture = BuildConfig.DEBUG && allowLocalTestHttp && url.host in listOf("localhost", "127.0.0.1", "::1")
if ((!url.isHttps && !fixture) || url.username.isNotEmpty() || url.password.isNotEmpty()) {
throw UpdateException(UpdateFailure.InvalidMetadata, "Update URLs must use credential-free HTTPS")
}
return url
}
private suspend fun request(value: String): Response {
var url = validateUrl(value)
repeat(6) {
val call = client.newCall(Request.Builder().url(url).get().build())
activeCall.set(call)
val response = suspendCancellableCoroutine<Response> { continuation ->
continuation.invokeOnCancellation { call.cancel() }
try {
continuation.resume(call.execute()) { _, response, _ -> response.close() }
} catch (error: Exception) { continuation.resumeWithException(error) }
}
if (response.code !in listOf(301, 302, 303, 307, 308)) return response
response.use {
val location = it.header("Location")
?: throw UpdateException(UpdateFailure.InvalidMetadata, "Redirect has no location")
url = validateUrl(url.resolve(location)?.toString()
?: throw UpdateException(UpdateFailure.InvalidMetadata, "Invalid redirect"))
}
}
throw UpdateException(UpdateFailure.HttpRefused, "Too many update redirects")
}
private suspend fun readJson(url: String): kotlinx.serialization.json.JsonElement = request(url).use {
requireSuccess(it)
val source = it.body.source()
if (source.request(MAX_METADATA_BYTES + 1) && source.buffer.size > MAX_METADATA_BYTES) {
throw UpdateException(UpdateFailure.InvalidMetadata, "Update metadata is too large")
}
try { Json.parseToJsonElement(source.readUtf8()) } catch (error: java.io.IOException) { throw error } catch (error: Exception) {
throw UpdateException(UpdateFailure.InvalidMetadata, "Invalid release JSON", error)
}
}
/** Installed-release notes share the same anonymous redirect policy as checks and assets. */
suspend fun releasesForLatest(url: String, limit: Int = 50): JsonArray {
val latest = validateUrl(url)
val list = latest.newBuilder()
var last = latest.pathSegments.lastIndex
while (last > 0 && latest.pathSegments[last].isEmpty()) {
list.removePathSegment(last)
last--
}
return readJson(list.removePathSegment(last).setQueryParameter("limit", limit.toString()).build().toString()).jsonArray
}
/** A latest 404 is empty only when the release-list endpoint successfully returns an empty array. */
suspend fun latest(url: String, abis: List<String>, sdk: Int): UpdateCandidate? {
try {
val release = request(url).use {
if (it.code == 404) {
if (releasesForLatest(url, 1).isEmpty()) return null
}
requireSuccess(it)
val source = it.body.source()
if (source.request(MAX_METADATA_BYTES + 1) && source.buffer.size > MAX_METADATA_BYTES) {
throw UpdateException(UpdateFailure.InvalidMetadata, "Release metadata is too large")
}
Json.parseToJsonElement(source.readUtf8()).jsonObject
}
return candidate(release, abis, sdk)
} catch (error: UpdateException) { throw error } catch (error: java.io.IOException) { throw error }
catch (error: kotlinx.coroutines.CancellationException) { throw error }
catch (error: Exception) { throw UpdateException(UpdateFailure.InvalidMetadata, "Invalid release metadata", error) }
}
private suspend fun candidate(release: JsonObject, abis: List<String>, sdk: Int): UpdateCandidate {
val assets = release.getValue("assets").jsonArray.map { it.jsonObject }
val manifestAsset = assets.single { it["name"]?.jsonPrimitive?.content == "wolfskin-update.json" }
val manifest = UpdateManifest.parse(readJson(manifestAsset.getValue("browser_download_url").jsonPrimitive.content).toString())
val selected = manifest.selectAsset(abis, sdk)
val asset = assets.single { it["name"]?.jsonPrimitive?.content == selected.name }
return UpdateCandidate(manifest, selected, validateUrl(asset.getValue("browser_download_url").jsonPrimitive.content).toString(),
release["body"]?.jsonPrimitive?.contentOrNull.orEmpty())
}
/** A fresh transfer always truncates partials; no unvalidated HTTP range concatenation. */
suspend fun download(candidate: UpdateCandidate, file: File, progress: (Long, Long?) -> Unit) {
currentCoroutineContext().ensureActive()
request(candidate.url).use { response ->
requireSuccess(response)
val length = response.body.contentLength().takeIf { it >= 0 }
if (length != null && length != candidate.asset.bytes) {
throw UpdateException(UpdateFailure.ChecksumMismatch, "APK length differs from manifest")
}
response.body.byteStream().use { input ->
file.outputStream().use { output ->
val buffer = ByteArray(64 * 1024)
var done = 0L
while (true) {
currentCoroutineContext().ensureActive()
val count = input.read(buffer)
if (count == -1) break
done += count
if (done > candidate.asset.bytes) throw UpdateException(UpdateFailure.ChecksumMismatch, "APK exceeds manifest length")
output.write(buffer, 0, count)
progress(done, length)
}
output.fd.sync()
}
}
}
}
private fun requireSuccess(response: Response) {
if (!response.isSuccessful) throw UpdateException(UpdateFailure.HttpRefused, "Update server refused request (HTTP ${response.code})")
}
companion object { private const val MAX_METADATA_BYTES = 1024L * 1024 }
}

View file

@ -0,0 +1,86 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv.update
import java.io.File
import java.security.MessageDigest
/** Actual APK identity, read independently of channel metadata. */
data class ApkIdentity(val packageId: String, val version: InstalledVersion, val signers: Set<String>)
/** Android boundary replaced by an in-process fixture in controller tests. */
interface UpdatePlatform {
fun installed(): ApkIdentity
fun inspect(file: File): ApkIdentity?
fun hasInstallPermission(): Boolean
fun openPermissionSettings(): Boolean
fun install(file: File, outcome: (InstallOutcome) -> Unit)
}
/** Installer results do not imply the running process has been replaced. */
enum class InstallOutcome { Pending, Success, Cancelled, Failed, Unavailable, InsufficientStorage }
/** Private staging; a metadata commit occurs only after independently verifying the APK. */
class VerifiedUpdateStore(val directory: File, private val platform: UpdatePlatform) {
val partial = File(directory, "download.part")
val apk = File(directory, "verified.apk")
private val record = File(directory, "ready.json")
fun prepare() {
if (!directory.isDirectory && !directory.mkdirs()) {
throw UpdateException(UpdateFailure.InsufficientStorage, "Cannot create private update storage")
}
}
fun verify(file: File, manifest: UpdateManifest, asset: UpdateAsset) {
if (file.length() != asset.bytes || sha256(file) != asset.sha256) {
throw UpdateException(UpdateFailure.ChecksumMismatch, "APK length or checksum does not match")
}
val identity = platform.inspect(file)
?: throw UpdateException(UpdateFailure.InvalidMetadata, "Android could not inspect the APK")
val installed = platform.installed()
if (identity.packageId != installed.packageId || identity.packageId != manifest.packageId) {
throw UpdateException(UpdateFailure.WrongPackage, "APK belongs to a different application")
}
if (identity.signers != installed.signers || identity.signers != setOf(manifest.signerSha256)) {
throw UpdateException(UpdateFailure.WrongSigner, "APK signer does not match this installation")
}
if (!UpdateChannel.isNewer(identity.version.versionCode, installed.version.versionCode)) {
throw UpdateException(UpdateFailure.LowerVersionCode, "APK is not newer than the installed application")
}
if (identity.version.versionCode != manifest.versionCode || identity.version.versionName != manifest.versionName) {
throw UpdateException(UpdateFailure.InvalidMetadata, "APK version differs from manifest")
}
}
fun commit(manifest: UpdateManifest, asset: UpdateAsset) {
verify(partial, manifest, asset)
check(partial.renameTo(apk)) { "Could not commit verified APK" }
val temporary = File(directory, "ready.pending")
temporary.outputStream().use { it.write(UpdateManifest.encode(manifest).toByteArray()); it.fd.sync() }
check(temporary.renameTo(record)) { "Could not commit verified metadata" }
}
/** Re-hash and re-inspect after process death; a marker alone never establishes trust. */
fun restore(abis: List<String>, sdk: Int): UpdateManifest? {
partial.delete()
File(directory, "ready.pending").delete()
if (!record.exists()) { apk.delete(); return null }
return try {
val manifest = UpdateManifest.parse(record.readText())
verify(apk, manifest, manifest.selectAsset(abis, sdk))
manifest
} catch (_: Exception) { clear(); null }
}
fun clear() { record.delete(); apk.delete(); partial.delete() }
companion object {
fun sha256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().use { input ->
val buffer = ByteArray(64 * 1024)
while (true) { val count = input.read(buffer); if (count < 0) break; digest.update(buffer, 0, count) }
}
return digest.digest().toHexString()
}
}
}