Merge pull request 'Add verified household TV updates and release tooling' (#60) from fm/tv-updater-core into main

This commit is contained in:
justinzeus 2026-11-07 21:33:42 +01:00
commit 57cf17aaad
33 changed files with 3033 additions and 112 deletions

View file

@ -4,7 +4,7 @@ WOLFSKIN_GRADLE_VOLUME ?= wolfskin-gradle
WOLFSKIN_SECRETS_DIR ?= /opt/stacks/ventoy/machine-parity/manifest-tank/secrets/wolfskin
export WOLFSKIN_BUILD_IMAGE WOLFSKIN_GRADLE_VOLUME WOLFSKIN_SECRETS_DIR
.PHONY: build-image apk apk-debug test release-tag
.PHONY: build-image apk apk-debug test release-tag tv-release tv-publish tv-tooling-test
build-image:
docker build --platform linux/amd64 --tag "$(WOLFSKIN_BUILD_IMAGE)" docker
@ -19,3 +19,13 @@ test: build-image
release-tag:
@bash docker/release-tag.sh
TV_RELEASE_DIR ?= /var/tmp/wolfskin-tv-release-$(VERSION)
tv-release: build-image
@python3 docker/tv_release.py --version "$(VERSION)" --version-code "$(VERSION_CODE)" --output "$(TV_RELEASE_DIR)" $(TV_RELEASE_FLAGS)
tv-publish:
@python3 docker/tv_publish.py --directory "$(TV_RELEASE_DIR)" $(TV_PUBLISH_FLAGS)
tv-tooling-test:
@python3 -m unittest discover -s docker -p 'test_tv_*.py' -v

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

@ -0,0 +1,455 @@
// 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 manifestSigner = UpdateChannel.SIGNER
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 settingsOpens = 0
var inspectAction: (() -> Unit)? = null
var outcome = InstallOutcome.Cancelled
override fun installed() = installed
override fun inspect(file: File): ApkIdentity { inspectAction?.invoke(); return archive }
override fun hasInstallPermission() = permission
override fun openPermissionSettings(): Boolean { settingsOpens++; return settingsAvailable }
override fun install(file: File, asset: UpdateAsset, outcome: (InstallOutcome) -> Unit) { installs++; outcome(this.outcome) }
}
private val preferences = object : UpdatePreferences {
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", manifestSigner,
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)
val metadata = controller.release.value
assertEquals(controller.state.value, metadata)
controller.download(); advanceUntilIdle()
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
assertEquals(metadata, controller.release.value)
assertEquals(0, platform.installs)
val restored = controller(this); advanceUntilIdle()
assertEquals(UpdateUiState.Ready("1.1.1"), restored.state.value)
assertEquals(metadata, restored.release.value)
restored.install(); advanceUntilIdle()
assertEquals(UpdateUiState.InstallCancelled, restored.state.value)
assertEquals(1, platform.installs)
assertTrue(File(directory, "verified.apk").exists())
}
@Test fun existingManifestOnlyReadyRecordRemainsReadable() = runTest {
val old = controller(this); old.check(true); advanceUntilIdle(); old.download(); advanceUntilIdle()
val metadata = old.release.value!!
assertEquals(manifest(), UpdateManifest.parse(File(directory, "ready.json").readText()))
File(directory, "ready.json").writeText(UpdateManifest.encode(manifest()))
val recreated = controller(this); advanceUntilIdle()
assertEquals(UpdateUiState.Ready("1.1.1"), recreated.state.value)
assertEquals(metadata.copy(notes = ""), recreated.release.value)
assertEquals(0, platform.installs)
}
@Test fun equalCodeIsUpToDateAndOlderCodeIsNotASuccess() = runTest {
code = 65
val equal = controller(this); equal.check(true); advanceUntilIdle()
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 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 {
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 permissionDismissalRetainsReadyAndInstallReopensSettings() = runTest {
val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
val metadata = controller.release.value
platform.permission = false
controller.install(); advanceUntilIdle()
assertEquals(UpdateUiState.PermissionNeeded, controller.state.value)
controller.install(); advanceUntilIdle()
assertEquals(2, platform.settingsOpens)
controller.cancel(); advanceUntilIdle()
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
assertEquals(metadata, controller.release.value)
assertArrayEquals(apk, File(directory, "verified.apk").readBytes())
controller.install(); advanceUntilIdle()
assertEquals(UpdateUiState.PermissionNeeded, controller.state.value)
assertEquals(3, platform.settingsOpens)
assertEquals(0, platform.installs)
}
@Test fun handoffDismissalKeepsReadyWithoutAllowingAnotherOwnedSession() = runTest {
val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
platform.outcome = InstallOutcome.Pending
controller.install(); advanceUntilIdle()
controller.cancel(); advanceUntilIdle()
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
controller.handleInstallOutcome(InstallOutcome.Pending); advanceUntilIdle()
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
val before = requests.size
controller.install(); advanceUntilIdle()
controller.check(true); advanceUntilIdle()
controller.download(); advanceUntilIdle()
assertEquals(1, platform.installs)
assertEquals(before, requests.size)
assertArrayEquals(apk, File(directory, "verified.apk").readBytes())
controller.handleInstallOutcome(InstallOutcome.Cancelled); advanceUntilIdle()
controller.install(); advanceUntilIdle()
assertEquals(2, platform.installs)
}
@Test fun dismissalDuringInstallVerificationDoesNotDiscardVerifiedBytes() = runTest {
val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
platform.inspectAction = { controller.cancel() }
controller.install(); advanceUntilIdle()
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
assertArrayEquals(apk, File(directory, "verified.apk").readBytes())
assertEquals(0, platform.installs)
}
@Test fun corruptPersistedBytesAndUnverifiedPartialsAreDiscarded() = runTest {
val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
File(directory, "verified.apk").appendText("corrupt")
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 failuresIdentifyCheckDownloadAndInstallWithoutParsingMessages() = runTest {
val controller = controller(this)
releaseStatus = 403
controller.check(true); advanceUntilIdle()
assertEquals(UpdateOperation.Check, (controller.state.value as UpdateUiState.Failed).operation)
releaseStatus = 200
controller.retry(); advanceUntilIdle()
assetStatus = 403
controller.download(); advanceUntilIdle()
assertEquals(UpdateOperation.Download, (controller.state.value as UpdateUiState.Failed).operation)
assetStatus = 200
controller.retry(); advanceUntilIdle()
File(directory, "verified.apk").writeBytes(ByteArray(apk.size))
controller.install(); advanceUntilIdle()
val failed = controller.state.value as UpdateUiState.Failed
assertEquals(UpdateOperation.Install, failed.operation)
assertEquals(UpdateFailure.ChecksumMismatch, failed.kind)
assertFalse(File(directory, "ready.json").exists())
}
@Test fun interruptedWireDownloadDoesNotPersistReady() = runTest {
truncated = true; wireTruncated = true
val controller = controller(this); controller.check(true); advanceUntilIdle()
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())
controller.cancel(); advanceUntilIdle(); controller.install(); advanceUntilIdle()
assertEquals(1, platform.installs)
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())
replaced.check(true); advanceUntilIdle()
assertEquals(UpdateUiState.UpToDate, replaced.state.value)
}
@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,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)
}
}
}

View file

@ -0,0 +1,111 @@
// 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.io.InputStream
import java.io.OutputStream
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, asset: UpdateAsset, 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(asset.bytes)
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, asset.bytes).use { output ->
file.inputStream().use { copyVerifiedUpdate(it, output, asset) }
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 (error: Exception) {
sessionId?.let { runCatching { installer.abandonSession(it) } }
if (error is UpdateException) throw error
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 }
}
}
/** 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")
}
}

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,36 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv.update
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
/** Deterministic capture/test controller; no network, storage or Android hand-off. */
class FakeUpdateController(
initialState: UpdateUiState = UpdateUiState.Idle,
initialAutoCheck: Boolean = true,
initialVersion: InstalledVersion = InstalledVersion("1.1.1", 100001),
initialRelease: UpdateUiState.Available? = initialState as? UpdateUiState.Available,
) : UpdateController {
private val mutableState = MutableStateFlow(initialState)
private val mutableAutoCheck = MutableStateFlow(initialAutoCheck)
private val mutableVersion = MutableStateFlow(initialVersion)
private val mutableRelease = MutableStateFlow(initialRelease)
override val state = mutableState.asStateFlow()
override val autoCheck = mutableAutoCheck.asStateFlow()
override val installedVersion = mutableVersion.asStateFlow()
override val release = mutableRelease.asStateFlow()
val actions = mutableListOf<String>()
fun show(state: UpdateUiState) {
if (state is UpdateUiState.Available) mutableRelease.value = state
mutableState.value = state
}
fun showInstalled(version: InstalledVersion) { mutableVersion.value = version }
override fun check(manual: Boolean) { actions += "check:$manual" }
override fun download() { actions += "download" }
override fun cancel() { actions += "cancel" }
override fun install() { actions += "install" }
override fun retry() { actions += "retry" }
override fun onResume() { actions += "resume" }
override fun setAutoCheck(enabled: Boolean) { mutableAutoCheck.value = enabled }
}

View file

@ -0,0 +1,223 @@
// 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)
private val mutableRelease = MutableStateFlow<UpdateUiState.Available?>(null)
override val state = mutableState.asStateFlow()
override val installedVersion = mutableVersion.asStateFlow()
override val autoCheck = preferences.autoCheck
override val release = mutableRelease.asStateFlow()
private var candidate: UpdateCandidate? = null
private var ready: UpdateManifest? = null
private var operation: Job? = null
@Volatile private var installerActive = false
private var retryOperation: () -> Unit = { check(true) }
private val restored = scope.async {
store.restore(abis, sdk)?.let {
ready = it.manifest
mutableRelease.value = available(it.manifest, it.asset, it.notes)
mutableState.value = UpdateUiState.Ready(it.manifest.versionName)
}
}
@Synchronized
private fun runOperation(operationKind: UpdateOperation, action: suspend () -> Unit): Unit {
if (operation?.isActive == true) return
operation = scope.launch {
restored.await()
try { action() }
catch (error: CancellationException) { throw error }
catch (error: UpdateException) { mutableState.value = UpdateUiState.Failed(error.kind, error.message, operationKind) }
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", operationKind)
}
catch (_: Exception) { mutableState.value = UpdateUiState.Failed(UpdateFailure.InvalidMetadata, "Update could not be processed", operationKind) }
}
}
private fun systemOwnsFlow(current: UpdateUiState): Boolean =
installerActive || current == UpdateUiState.HandingOff || current == UpdateUiState.PermissionNeeded
override fun check(manual: Boolean): Unit = runOperation(UpdateOperation.Check) {
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) {
candidate = null; mutableRelease.value = null
mutableState.value = UpdateUiState.NoReleasePublished
return@runOperation
}
val installed = platform.installed()
mutableVersion.value = installed.version
if (release.manifest.packageId != installed.packageId) throw UpdateException(UpdateFailure.WrongPackage, "Release belongs to a different application")
if (installed.signers != setOf(release.manifest.signerSha256)) throw UpdateException(UpdateFailure.WrongSigner, "Release signer differs from this installation")
if (release.manifest.versionCode < installed.version.versionCode) throw UpdateException(UpdateFailure.LowerVersionCode, "Channel release is older than this installation")
if (release.manifest.versionCode == installed.version.versionCode) {
candidate = null; mutableRelease.value = null
mutableState.value = UpdateUiState.UpToDate
} else {
candidate = release
val metadata = available(release.manifest, release.asset, release.notes)
mutableRelease.value = metadata
mutableState.value = metadata
}
}
private fun available(manifest: UpdateManifest, asset: UpdateAsset, notes: String): UpdateUiState.Available {
val date = DateTimeFormatter.ofLocalizedDate(java.time.format.FormatStyle.MEDIUM).withLocale(Locale.getDefault())
.withZone(ZoneId.systemDefault()).format(Instant.parse(manifest.publishedAt))
return UpdateUiState.Available(manifest.versionName, asset.bytes, date, notes)
}
override fun download(): Unit = runOperation(UpdateOperation.Download) {
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, release.notes)
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() {
transport.cancel()
val previous = operation
if (!installerActive) previous?.cancel()
operation = scope.launch {
previous?.join()
store.partial.delete()
mutableState.value = ready?.let { UpdateUiState.Ready(it.versionName) }
?: candidate?.let { mutableRelease.value } ?: UpdateUiState.Idle
}
}
override fun install(): Unit = runOperation(UpdateOperation.Install) {
if (installerActive || state.value == UpdateUiState.HandingOff) return@runOperation
val manifest = ready ?: return@runOperation
retryOperation = { install() }
val asset = manifest.selectAsset(abis, sdk)
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
retryOperation = { if (candidate != null) download() else check(true) }
throw error
}
}
fun handleInstallOutcome(outcome: InstallOutcome) {
scope.launch {
restored.await()
installerActive = outcome == InstallOutcome.Pending || outcome == InstallOutcome.Success
if (ready == null && outcome != InstallOutcome.Pending) {
installerActive = false
retryOperation = { check(true) }
mutableState.value = UpdateUiState.Idle
onResume()
return@launch
}
mutableState.value = when (outcome) {
InstallOutcome.Pending -> if (state.value is UpdateUiState.Ready) state.value else UpdateUiState.HandingOff
InstallOutcome.Cancelled -> UpdateUiState.InstallCancelled
InstallOutcome.Unavailable -> UpdateUiState.NoInstaller
InstallOutcome.InsufficientStorage -> UpdateUiState.Failed(UpdateFailure.InsufficientStorage, "Android needs more storage to install the retained update", UpdateOperation.Install)
InstallOutcome.Failed -> UpdateUiState.Failed(UpdateFailure.InvalidMetadata, "Android could not install the update; the verified download is retained", UpdateOperation.Install)
InstallOutcome.Success -> UpdateUiState.HandingOff
}
if (outcome != InstallOutcome.Pending) onResume()
}
}
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) {
installerActive = false
store.clear(); ready = null; candidate = null; mutableRelease.value = null; mutableState.value = UpdateUiState.Idle
} else if (state.value == UpdateUiState.PermissionNeeded) {
// Returning from settings requires another explicit Install press.
mutableState.value = if (platform.hasInstallPermission()) UpdateUiState.Ready(manifest.versionName)
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,52 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv.update
import kotlinx.coroutines.flow.StateFlow
/** Shared process-scoped updater for Wolfskin and classic settings. */
interface UpdateController {
val state: StateFlow<UpdateUiState>
val autoCheck: StateFlow<Boolean>
val installedVersion: StateFlow<InstalledVersion>
val release: StateFlow<UpdateUiState.Available?>
fun check(manual: Boolean)
fun download()
/** Discard partial bytes; retain verified bytes and restore Ready without duplicating a system session. */
fun cancel()
fun install()
fun retry()
fun setAutoCheck(enabled: Boolean)
/** Refresh installed identity and unknown-source permission after Android returns. */
fun onResume()
}
/** PackageManager identity, refreshed after returning from the system installer. */
data class InstalledVersion(val versionName: String, val versionCode: Long)
/** Only verified, app-private bytes may produce Ready. Checking never initiates download. */
sealed interface UpdateUiState {
data object Idle : UpdateUiState
data object Checking : UpdateUiState
data object UpToDate : UpdateUiState
data object NoReleasePublished : UpdateUiState
data class Available(val versionName: String, val bytes: Long, val dateText: String, val notes: String) : UpdateUiState
data class Downloading(val bytesDone: Long, val bytesTotal: Long?) : UpdateUiState
data class Ready(val versionName: String) : UpdateUiState
data object PermissionNeeded : UpdateUiState
data object HandingOff : UpdateUiState
data object InstallCancelled : UpdateUiState
data class Failed(val kind: UpdateFailure, val message: String, val operation: UpdateOperation) : UpdateUiState
data object Offline : UpdateUiState
data object NoInstaller : UpdateUiState
}
/** The failed action is structured; UI must not infer it from a reason string. */
enum class UpdateOperation { Check, Download, Install }
/** Failures are distinct from a successful version comparison or an empty channel. */
enum class UpdateFailure {
Network, HttpRefused, InvalidMetadata, UnsupportedAbi, InsufficientStorage,
ChecksumMismatch, WrongPackage, WrongSigner, LowerVersionCode,
}

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

View file

@ -11,7 +11,11 @@ make test # app default/debug suite plus wolfskin-design JVM/capture suite
make apk-debug # default debug, no secrets; MPV stub and no extra codec decoders
```
APKs remain under `app/build/outputs/apk/default/{release,debug}/`. Debug builds
Offline JVM tests use instrumented Android 9, 11 and 14 SDK jars. Cache preparation
resolves each SDK independently; putting all three versions in one Gradle
configuration selects only the newest and prevents older-SDK tests from starting.
APKs remain under `app/build/outputs/apk/default/{release,debug,optimized}/`. Debug builds
are not playback-complete and must not be distributed as releases. Upstream's
`.github/workflows` and dependency version catalog are unchanged; release builds
use the catalog's exact Wholphin extension version (currently `0.2.4`). Do not
@ -27,6 +31,159 @@ Upstream enables JVM unit tests only for debug variants; there is no
`testDefaultReleaseUnitTest` task. `make test` uses the closest default-flavor
task without changing upstream's test configuration.
## Household update channel
The household assembly remains `defaultOptimized`: R8/resource shrinking,
non-debuggable, baseline profiles, pinned playback extensions and no debug
fixtures. Its installation identity is `org.justinvisser.wolfskin.tv.opt`.
The persistent external Android debug keystore is now the **household signing
authority**, not a disposable development key. Certificate SHA-256:
`182bac72e292b9ea4d3d4e6383f92eb2161fa28ba5afcff0337cb7d5a9d5341d`.
Never print/copy that keystore into artifacts or regenerate it for a release.
The household wrapper fails if it is missing; protect and escrow the original.
Same package + same signer + strictly higher Android versionCode allows an
in-place upgrade retaining Jellyfin logins, databases and preferences. Never
uninstall, clear data, change identity or replace the key as an update recovery
step. An older ephemeral signer is a separate, owner-approved migration gate;
this change does not prove the TV's installed signer or retained-login behavior.
The dedicated public distribution repository is
`justinzeus/wolfskin-tv-updates`; its default API is
`https://git.justinvisser.org/api/v1/repos/justinzeus/wolfskin-tv-updates/releases/latest`.
Repository creation and each actual publication are separate owner gates.
Neither command creates a repository. Source remains in `justinzeus/wolfskin`.
Initialize the distribution repository's default branch (for example with a
README) before its first draft; Forgejo needs a commit on which to create a
distribution tag. That tag is not the source tag in the separate source repo.
The old production package keeps its old release API and parser. Only the Opt
package migrates the exact old default saved URL; intentional custom URLs remain.
Installed-release notes use the household channel too.
### Build, inspect and reserve a code
```sh
make tv-release VERSION=1.1.1 VERSION_CODE=100001 \
TV_RELEASE_DIR=/var/tmp/wolfskin-tv-release-1.1.1
# Prerequisites only: no build, tag, ledger mutation or publication.
make tv-release VERSION=1.1.1 VERSION_CODE=100001 \
TV_RELEASE_DIR=/var/tmp/wolfskin-tv-release-dry TV_RELEASE_FLAGS=--dry-run
```
Required: a clean committed source checkout, the existing persistent key, native
package credentials through the existing secret mount, no local AAR overrides,
and an explicit code above the committed ledgers at HEAD and freshly fetched
`origin/main`, plus every local consumed-code reservation. Fetch failure refuses
the build, including a dry-run. Known Opt code 65 seeds the ledger; old production
code 28 and Wholphin code 62 belong to different packages. Proposed first household
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,
version/code, signer, non-debuggable flag, minimum SDK, native ABI inventory,
fixture exclusion and both dexopt profiles. It emits four standalone APKs:
`Wolfskin-armeabi-v7a.apk`, `Wolfskin-arm64-v8a.apk`, `Wolfskin-x86_64.apk`,
and universal `Wolfskin.apk`; plus `wolfskin-update.json`, `RELEASE-NOTES.md`
and `SHA256SUMS`. Hashes and sizes come from final signed bytes. Universal
32-bit x86 is not MPV-complete and is not a supported playback ABI.
Manifest schema 1 contains `packageId`, `versionName`, `versionCode`,
`publishedAt` (UTC ISO-8601), immutable `sourceCommit`, `sourceTag`,
`signerSha256`, and `assets[]` with `abi`, `name`, `bytes`, `sha256`, `minSdk`.
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>`.
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.
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.
Keep `private/` private: build/dependency logs, R8 mapping/effective configuration,
APK metadata/certificate inspection and native/ZIP inventory. Publication uploads
only the seven named public files, never that directory, credentials or keys.
Review release notes and corresponding-source/native links before promotion.
### Explicit draft publication
```sh
make tv-publish TV_RELEASE_DIR=/var/tmp/wolfskin-tv-release-1.1.1 \
TV_PUBLISH_FLAGS=--dry-run
# Only after owner approval to upload:
make tv-publish TV_RELEASE_DIR=/var/tmp/wolfskin-tv-release-1.1.1
# Only after owner approval to publish the verified draft (use its printed id):
make tv-publish TV_RELEASE_DIR=/var/tmp/wolfskin-tv-release-1.1.1 \
TV_PUBLISH_FLAGS='--draft-id <id> --promote'
make tv-tooling-test
```
The publisher reads git's credential helper into process memory, not arguments,
logs or files. It creates a draft, uploads all public files, then downloads and
hashes their bytes while still draft. Existing matching drafts can be resumed
and reverified. Upload/verification failures remain draft. Promotion requires
the explicit flag; then anonymous latest/asset hash checks must pass. A failed
post-promotion check attempts withdrawal to draft and reports failure, not
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
cannot be redirected to a different repository by a CLI repository argument.
Dry-run never fetches a credential or makes publication requests.
### Client trust and installation
`UpdateController` is the process-scoped UI contract with `StateFlow` state,
shared auto-check preference, refreshed PackageManager installed version and
explicit check/download/cancel/install/retry/resume actions. Its capture fake
does not perform real operations. Classic Opt update controls use that same
controller; the old package retains its original updater.
`release: StateFlow<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:
HTTPS, no URL user-info, no cookies/Jellyfin token/repository credential.
Loopback HTTP is available only through the test transport constructor flag.
It selects armeabi-v7a first when supported, then remaining device ABIs and
a universal fallback, subject to minSdk. Universal fallback is limited to
the channel's supported playback ABIs: armeabi-v7a, arm64-v8a and x86_64.
It cannot make an unsupported processor compatible. Empty-release-list success
is distinct from a latest-endpoint 404, authorization failure, invalid metadata
or up-to-date code.
Downloads are app-private. Retry starts a new truncated partial rather than
concatenating unvalidated ranges; cancel and process recreation discard partials.
Length, SHA-256, APK PackageManager package/version/current signer and manifest
signer must agree before Ready. Restoring Ready re-hashes/re-inspects the APK.
Checksum files on the same server are not an independent trust authority:
the installed Android signer is the installation boundary.
EOF never starts installation. Install requires a separate OK, per-package
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.
Denial, cancellation and missing system handlers retain the verified download.
No broad FileProvider, public Downloads write, silent install or automatic
uninstall is involved. PackageManager is re-read on return; a successful callback
is not proof that the running process is the new version. Real OEM confirmation,
process replacement and retained-login acceptance remain device-walk gates.
### Full-suite test diagnostics
The Gradle daemon heap (`org.gradle.jvmargs` in `gradle.properties`) is separate
@ -89,20 +246,22 @@ wait for either response. The minified SDK log tag is not stable across builds.
The `optimized` build type inherits `release`: R8 optimization/minification,
resource shrinking, non-debuggable code, dependency consumer keep rules and
dependency baseline profiles. It changes only signing (the Android debug key),
package (`org.justinvisser.wolfskin.tv.opt`) and launcher label (`Wolfskin (Opt)`).
The launcher icon/banner and release-compatible version name are unchanged:
the shared upgrade/updater parser rejects extra suffixes such as `-opt`.
`make apk` remains the clean, release-key-signed production build. Debug and
optimized containers share the persistent debug key described below; release
signing is unchanged.
dependency baseline profiles. Its package (`org.justinvisser.wolfskin.tv.opt`),
launcher label (`Wolfskin (Opt)`) and persistent signer remain unchanged. It is
the household release assembly, with plain versionName `1.1.1` and an explicit
monotonic versionCode; source commit identity is recorded separately.
`make apk` remains the old `org.justinvisser.wolfskin.tv` production assembly and
release signer. It must never publish to the household channel.
Build all four default-flavor outputs in one invocation through the container
wrapper, without a release keystore. On a host whose Gradle cache already holds
the pinned native extensions and their metadata:
For build-only diagnostics, build all four outputs through the container wrapper
with a deliberately non-release code. **Direct optimized builds must never be
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
bash docker/gradle.sh optimized --offline \
-PtvVersion=1.1.1 -PtvVersionCode=1 \
-PWholphinExtensionsUsername=JustinZeus \
-PWholphinExtensionsPassword=unused \
:app:assembleDefaultOptimized
@ -115,7 +274,8 @@ through the existing `ORG_GRADLE_PROJECT_WholphinExtensionsUsername` and
or securely seeded cached artifacts. No release signing inputs are needed.
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
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,
armeabi-v7a and x86_64. Keep
@ -958,14 +1118,12 @@ Jellyfin read by the selected person's saved Jellyfin user ID, so a lost
Conductarr operator or a renamed household profile does not hide that user's
own library. A different saved user ID still fails closed.
`make test` runs `OwnedSourceAuditTest` through the design module's JVM suite.
It scans the owned Kotlin sources in `wolfskin-design/src/main/java` and
`app/src/wolfskin`, rejecting untokened `.dp`/`.sp` conversions and numeric Color
constructors outside generated tokens, token font sizes below 12 sp, raw
household library labels, and page-counter/navigation wording. Its negative
cases plant each violation independently. Existing native capture checks also
verify rendered font sizes and wording. `LibraryProgressiveSurfaceTest` captures
the usable first chunk while the final read is still suspended.
`make test` keeps the native capture checks for rendered font sizes and visible
surface behavior, plus generated-token drift checks. Styling uses generated
tokens rather than raw dimensions. The source-regex wording/style audit was
removed: source spelling is not a consumer-visible behavior contract.
`LibraryProgressiveSurfaceTest` captures the usable first chunk while the final
read is still suspended.
## Toolchain and disk use
@ -1012,21 +1170,25 @@ the checkout when building on another host, for example:
WOLFSKIN_DEBUG_KEYSTORE="$HOME/.local/share/wolfskin-signing/debug.keystore" make apk-debug
```
The wrapper creates a missing key once using the build image's `keytool`: RSA
2048, 10,000-day validity, alias `androiddebugkey`, store/key passwords `android`,
and subject `CN=Android Debug`. The containing directory is mode `0700`, the
file mode `0600`. Existing keys are reused, never regenerated; generation errors
abort the build rather than falling back to an ephemeral container key.
For ordinary debug builds only, the wrapper creates a missing key once using
the build image's `keytool`: RSA 2048, 10,000-day validity, alias
`androiddebugkey`, store/key passwords `android`, subject `CN=Android Debug`.
Household and optimized modes require the existing key and refuse generation.
The directory is mode `0700`, the file mode `0600`; existing keys are reused.
Generation errors abort rather than falling back to an ephemeral container key.
The file is bind-mounted read-only at `/tmp/.android/debug.keystore`, with
`ANDROID_USER_HOME=/tmp/.android`, for every non-release Gradle container.
Keep and back up this file across clean builds, disposable worktrees, container
rebuilds and Gradle cache resets. Never commit it. The standard debug passwords
are public; this key is for local debug/optimized builds, not release signing.
are public; the private key is nevertheless the permanent household signing
authority. It is distinct from the old production package's release signer.
### Preserve household TV data
Install and update the household TV app only in place with `adb install -r`,
using the same package and persistent debug signer. Its certificate SHA-256 is:
Normal household updates use the in-app verified download and explicit Android
confirmation, preserving the same package and persistent signer. A separately
authorized bootstrap may use operator `adb install -r`; that is not a substitute
for the in-app update acceptance walk. The certificate SHA-256 is:
```text
182bac72e292b9ea4d3d4e6383f92eb2161fa28ba5afcff0337cb7d5a9d5341d
@ -1044,9 +1206,10 @@ a **keep app data** choice during interactive uninstall. Choose it when
available; older Android versions ignore the attribute. This is a retention
prompt, not an automatic backup or a guarantee against data clearing.
For an in-place update, compare `dumpsys package <pkg>` before and after:
`firstInstallTime` must remain unchanged. The guarded installer that checks
the signer and this timestamp lives in the operator's tooling, not this repo.
An operator can compare `dumpsys package <pkg>` before and after an in-place
update: `firstInstallTime` must remain unchanged. The in-app service checks
package, current signer, checksum and higher code; real-device timestamp and
retained-login proof remain a separate operator walk, not a JVM-test claim.
## Release credentials

View file

@ -45,6 +45,10 @@ else
signing_directory=$(dirname "$debug_keystore")
mkdir -p "$signing_directory"
chmod 700 "$signing_directory"
if [[ ( $mode == household || $mode == optimized ) && ! -s $debug_keystore ]]; then
printf 'Household signing key is required; refusing to generate an identity\n' >&2
exit 1
fi
if [[ ! -e $debug_keystore ]]; then
temporary_directory=$(mktemp -d "$signing_directory/.debug-keystore.XXXXXX")
trap 'rm -rf -- "$temporary_directory"' EXIT
@ -66,5 +70,15 @@ else
chmod 600 "$debug_keystore"
arguments+=(--env ANDROID_USER_HOME=/tmp/.android
--mount "type=bind,src=$debug_keystore,dst=/tmp/.android/debug.keystore,readonly")
docker run "${arguments[@]}" "$image" ./gradlew "${gradle_arguments[@]}" "$@"
if [[ $mode == household ]]; then
[[ -s $secrets/github-packages-token ]] || { printf 'Missing native package credential\n' >&2; exit 1; }
arguments+=(--mount "type=bind,src=$secrets/github-packages-token,dst=/run/native-token,readonly")
docker run "${arguments[@]}" "$image" bash -euc '
export ORG_GRADLE_PROJECT_WholphinExtensionsUsername=JustinZeus
export ORG_GRADLE_PROJECT_WholphinExtensionsPassword=$(cat /run/native-token)
exec ./gradlew "$@"
' bash "${gradle_arguments[@]}" "$@"
else
docker run "${arguments[@]}" "$image" ./gradlew "${gradle_arguments[@]}" "$@"
fi
fi

371
docker/test_tv_publish.py Normal file
View file

@ -0,0 +1,371 @@
"""Exercise the release CLI against a local Forgejo-shaped HTTP server."""
import email.parser
import email.policy
import hashlib
import json
import os
import subprocess
import sys
import tempfile
import threading
import unittest
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from unittest.mock import patch
from tv_publish import REQUIRED, Forge
from tv_release import PACKAGE, SIGNER
class FakeForge:
"""Draft assets require authentication; anonymous reads work only after promotion."""
def __init__(self) -> None:
self.release: dict[str, Any] = {}
self.assets: dict[str, bytes] = {}
self.events: list[tuple[str, bool]] = []
self.corrupt_draft = False
self.corrupt_public = False
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
class Handler(BaseHTTPRequestHandler):
def log_message(self, format: str, *args: Any) -> None:
pass
def respond(self, value: Any, code: int = 200) -> None:
data = value if isinstance(value, bytes) else json.dumps(value).encode()
self.send_response(code)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_POST(self) -> None:
authenticated = bool(self.headers.get("Authorization"))
fixture.events.append(("POST " + self.path, authenticated))
if not authenticated:
self.respond({}, 401)
return
data = self.rfile.read(int(self.headers["Content-Length"]))
if self.path.endswith("/releases"):
fixture.release = json.loads(data) | {"id": 17, "assets": []}
self.respond(fixture.release)
else:
name = urllib.parse.parse_qs(
urllib.parse.urlsplit(self.path).query
)["name"][0]
message = email.parser.BytesParser(
policy=email.policy.default
).parsebytes(
b"Content-Type: "
+ self.headers["Content-Type"].encode()
+ b"\r\n\r\n"
+ data
)
fixture.assets[name] = next(message.iter_parts()).get_payload(
decode=True
)
asset = {
"name": name,
"browser_download_url": fixture.base + "/assets/" + name,
"size": len(fixture.assets[name]),
}
fixture.release["assets"].append(asset)
self.respond(asset)
def do_PATCH(self) -> None:
fixture.events.append(
("PATCH", bool(self.headers.get("Authorization")))
)
fixture.release.update(
json.loads(self.rfile.read(int(self.headers["Content-Length"])))
)
self.respond(fixture.release)
def do_GET(self) -> None:
authenticated = bool(self.headers.get("Authorization"))
fixture.events.append(("GET " + self.path, authenticated))
if fixture.redirect_url:
self.send_response(302)
self.send_header("Location", fixture.redirect_url)
self.end_headers()
return
if self.path.startswith("/assets/"):
if fixture.release["draft"] and not authenticated:
self.respond({}, 404)
return
data = fixture.assets[self.path.removeprefix("/assets/")]
corrupt = (
fixture.corrupt_draft
if fixture.release["draft"]
else fixture.corrupt_public
)
self.respond(data + b"corrupt" if corrupt else data)
elif self.path == "/channel/wolfskin-update.json":
self.respond(
fixture.channel_manifest
| {"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:
self.respond(fixture.release)
self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
self.base = f"http://127.0.0.1:{self.server.server_port}"
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
def close(self) -> None:
"""Release fixture resources after each independent publication scenario."""
self.server.shutdown()
self.server.server_close()
self.thread.join()
class PublishTest(unittest.TestCase):
"""Permanent tests assert safety transitions and real uploaded bytes, not script wording."""
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.directory = Path(self.temporary.name)
self.forge = FakeForge()
self.addCleanup(self.forge.close)
self.addCleanup(self.temporary.cleanup)
assets = []
for abi in ("armeabi-v7a", "arm64-v8a", "x86_64", "universal"):
name = "Wolfskin.apk" if abi == "universal" else f"Wolfskin-{abi}.apk"
data = f"fixture APK bytes for {abi}".encode()
(self.directory / name).write_bytes(data)
assets.append(
{
"name": name,
"abi": abi,
"bytes": len(data),
"sha256": hashlib.sha256(data).hexdigest(),
"minSdk": 23,
}
)
manifest = {
"schemaVersion": 1,
"packageId": PACKAGE,
"versionName": "1.1.1",
"versionCode": 100001,
"publishedAt": "2026-11-07T12:00:00Z",
"sourceCommit": "a" * 40,
"sourceTag": "pwolfskin-opt-v1.1.1",
"signerSha256": SIGNER,
"assets": assets,
}
self.forge.channel_manifest = manifest
(self.directory / "wolfskin-update.json").write_text(json.dumps(manifest))
(self.directory / "RELEASE-NOTES.md").write_text("Fixture release notes")
self.checksums()
def checksums(self) -> None:
"""Hash fixture files after deliberately constructing or mutating the bundle."""
(self.directory / "SHA256SUMS").write_text(
"".join(
f"{hashlib.sha256((self.directory / name).read_bytes()).hexdigest()} {name}\n"
for name in sorted(REQUIRED - {"SHA256SUMS"})
)
)
def cli(self, *flags: str) -> subprocess.CompletedProcess[str]:
"""Drive the real CLI and real git credential-helper protocol with a fake credential."""
environment = os.environ | {
"GIT_CONFIG_COUNT": "2",
"GIT_CONFIG_KEY_0": "credential.helper",
"GIT_CONFIG_VALUE_0": "",
"GIT_CONFIG_KEY_1": "credential.helper",
"GIT_CONFIG_VALUE_1": "!printf 'username=fixture\npassword=fixture-secret\n'",
}
result = subprocess.run(
[
sys.executable,
str(Path(__file__).with_name("tv_publish.py")),
"--directory",
str(self.directory),
"--test-server",
self.forge.base,
*flags,
],
capture_output=True,
text=True,
env=environment,
timeout=30,
check=False,
)
self.assertNotIn("fixture-secret", result.stdout + result.stderr)
return result
def test_dry_run_never_requests_or_fetches_credentials(self) -> None:
result = self.cli("--dry-run", "--promote")
self.assertEqual(0, result.returncode, result.stderr)
self.assertEqual([], self.forge.events)
def test_draft_then_explicit_promotion_reverifies_all_bytes(self) -> None:
result = self.cli()
self.assertEqual(0, result.returncode, result.stderr)
self.assertTrue(self.forge.release["draft"])
self.assertEqual(REQUIRED, set(self.forge.assets))
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")
self.assertEqual(0, result.returncode, result.stderr)
self.assertFalse(self.forge.release["draft"])
for name, data in self.forge.assets.items():
self.assertEqual((self.directory / name).read_bytes(), data)
def test_corrupt_uploaded_bytes_remain_draft(self) -> None:
self.forge.corrupt_draft = True
result = self.cli("--promote")
self.assertNotEqual(0, result.returncode)
self.assertTrue(self.forge.release["draft"])
self.assertFalse(any(event == "PATCH" for event, _ in self.forge.events))
def test_failed_anonymous_verification_withdraws_promoted_release(self) -> None:
self.forge.corrupt_public = True
result = self.cli("--promote")
self.assertNotEqual(0, result.returncode)
self.assertTrue(self.forge.release["draft"])
self.assertEqual(2, sum(event == "PATCH" for event, _ in self.forge.events))
def test_invalid_local_checksum_never_creates_draft(self) -> None:
(self.directory / "Wolfskin.apk").write_bytes(b"tampered")
result = self.cli()
self.assertNotEqual(0, result.returncode)
self.assertEqual([], self.forge.events)
def test_manifest_size_mismatch_never_creates_draft(self) -> None:
path = self.directory / "wolfskin-update.json"
manifest = json.loads(path.read_text())
manifest["assets"][0]["bytes"] += 1
path.write_text(json.dumps(manifest))
self.checksums()
result = self.cli()
self.assertNotEqual(0, result.returncode)
self.assertEqual([], self.forge.events)
def test_empty_manifest_asset_set_never_creates_draft(self) -> None:
path = self.directory / "wolfskin-update.json"
manifest = json.loads(path.read_text())
manifest["assets"] = []
path.write_text(json.dumps(manifest))
self.checksums()
result = self.cli()
self.assertNotEqual(0, result.returncode)
self.assertEqual([], self.forge.events)
def test_credential_helper_tracing_is_disabled(self) -> None:
trace = self.directory / "credential.trace"
with patch.dict(os.environ, {"GIT_TRACE": str(trace)}):
result = self.cli()
self.assertEqual(0, result.returncode, result.stderr)
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:
other = FakeForge()
self.addCleanup(other.close)
self.forge.redirect_url = other.base + "/asset"
forge = Forge(self.forge.base, "Basic fixture-authorization", test_server=True)
with self.assertRaises(ValueError):
forge.verify(self.forge.base + "/redirect", "0" * 64, authenticated=True)
self.assertEqual([], other.events)
if __name__ == "__main__":
unittest.main()

159
docker/test_tv_release.py Normal file
View file

@ -0,0 +1,159 @@
"""Exercise source cleanliness, monotonic codes and fail-closed signing prerequisites."""
import contextlib
import io
import os
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import tv_release
class ReleaseInputTest(unittest.TestCase):
"""Use a disposable Git fixture, never a build or a real signing key."""
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name) / "source"
self.root.mkdir()
(self.root / "docker").mkdir()
(self.root / "docker/tv-version-codes").write_text("65 installed-opt\n")
self.git("init", "--quiet")
self.git("add", "docker/tv-version-codes")
self.git(
"-c",
"user.name=Fixture",
"-c",
"user.email=fixture@example.invalid",
"commit",
"--quiet",
"-m",
"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
tv_release.ROOT = self.root
self.addCleanup(setattr, tv_release, "ROOT", self.previous_root)
def git(self, *args: str) -> str:
"""Run the isolated source fixture's actual Git commands."""
return subprocess.check_output(["git", *args], cwd=self.root, text=True).strip()
def test_new_code_identifies_clean_source(self) -> None:
self.assertEqual(
self.git("rev-parse", "HEAD"), tv_release.validate_inputs("1.1.1", 100001)
)
def test_reused_and_lower_codes_are_rejected(self) -> None:
for code in (1, 64, 65):
with self.subTest(code=code), self.assertRaises(ValueError):
tv_release.validate_inputs("1.1.1", code)
def test_dirty_source_is_rejected(self) -> None:
(self.root / "untracked").write_text("not part of the source commit")
with self.assertRaises(ValueError):
tv_release.validate_inputs("1.1.1", 100001)
def test_unparseable_version_and_invalid_codes_are_rejected(self) -> None:
for version, code in (
("1.1.1-opt", 100001),
("1.1.1", 0),
("1.1.1", 2100000001),
):
with (
self.subTest(version=version, code=code),
self.assertRaises(ValueError),
):
tv_release.validate_inputs(version, code)
def test_missing_signing_key_never_generates_identity(self) -> None:
key = Path(self.temporary.name) / "absent-key"
with (
patch.dict(os.environ, {"WOLFSKIN_DEBUG_KEYSTORE": str(key)}),
self.assertRaises(ValueError),
):
tv_release.release(
"1.1.1", 100001, Path(self.temporary.name) / "output", True
)
self.assertFalse(key.exists())
def test_dry_run_never_builds_tags_or_mutates_ledger(self) -> None:
key = Path(self.temporary.name) / "fixture-key-presence-only"
key.write_bytes(b"not an actual keystore; dry run must not open it")
output = Path(self.temporary.name) / "output"
with (
patch.dict(os.environ, {"WOLFSKIN_DEBUG_KEYSTORE": str(key)}),
contextlib.redirect_stdout(io.StringIO()),
):
tv_release.release("1.1.1", 100001, output, True)
self.assertFalse(output.exists())
self.assertEqual("", self.git("tag", "--list"))
self.assertEqual(
"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__":
unittest.main()

3
docker/tv-version-codes Normal file
View file

@ -0,0 +1,3 @@
# Household org.justinvisser.wolfskin.tv.opt codes already used.
# Append successful tv-release codes; commit this ledger before another release.
65 installed-opt

413
docker/tv_publish.py Executable file
View file

@ -0,0 +1,413 @@
#!/usr/bin/env python3
"""Upload a verified draft; promote only with explicit --promote owner approval."""
import argparse
import base64
import datetime
import hashlib
import json
import os
import re
import subprocess
import urllib.error
import urllib.parse
import urllib.request
import uuid
from collections.abc import Iterable
from pathlib import Path
from typing import Any
SERVER = "https://git.justinvisser.org"
REPOSITORY = "justinzeus/wolfskin-tv-updates"
REQUIRED = {
"Wolfskin-armeabi-v7a.apk",
"Wolfskin-arm64-v8a.apk",
"Wolfskin-x86_64.apk",
"Wolfskin.apk",
"wolfskin-update.json",
"RELEASE-NOTES.md",
"SHA256SUMS",
}
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):
"""Validate every redirect before forwarding distribution-tool credentials."""
def redirect_request(
self, req: Any, fp: Any, code: int, msg: str, headers: Any, newurl: str
) -> None:
return None
class Forge:
"""Credential-scoped release API; credentials exist only in process memory."""
def __init__(
self, server: str, authorization: str, test_server: bool = False
) -> None:
self.server = server.rstrip("/")
self.origin = urllib.parse.urlsplit(self.server)
self.authorization = authorization
self.test_server = test_server
self.opener = urllib.request.build_opener(NoRedirect)
self.api = self.server + "/api/v1/repos/" + REPOSITORY
def request(
self,
method: str,
url: str,
data: Any = None,
content_type: str | None = None,
length: int | None = None,
authenticated: bool = True,
) -> Any:
"""Reject cross-origin credential forwarding and non-HTTPS production URLs."""
for _ in range(6):
parsed = urllib.parse.urlsplit(url)
local = self.test_server and parsed.hostname in (
"localhost",
"127.0.0.1",
"::1",
)
if (
parsed.username
or parsed.password
or (parsed.scheme != "https" and not local)
):
raise ValueError(
"Distribution requests require credential-free HTTPS URLs"
)
if authenticated and (parsed.scheme, parsed.netloc) != (
self.origin.scheme,
self.origin.netloc,
):
raise ValueError(
"Refusing to send release credentials to another origin"
)
headers = {"Accept": "application/json"}
if authenticated:
headers["Authorization"] = self.authorization
if content_type:
headers["Content-Type"] = content_type
if length is not None:
headers["Content-Length"] = str(length)
request = urllib.request.Request(
url, data=data, method=method, headers=headers
)
try:
return self.opener.open(request, timeout=60)
except urllib.error.HTTPError as error:
if method != "GET" or error.code not in (301, 302, 303, 307, 308):
status = error.code
error.close()
raise ForgeHttpError(method, status) from None
location = error.headers.get("Location")
error.close()
if not location:
raise ValueError("Forgejo redirect has no location")
url = urllib.parse.urljoin(url, location)
raise ValueError("Too many Forgejo redirects")
def json(
self,
method: str,
path: str,
value: dict[str, Any] | None = None,
authenticated: bool = True,
) -> Any:
"""Read or mutate release metadata without exposing HTTP authorization."""
data = None if value is None else json.dumps(value).encode()
with self.request(
method,
self.api + path,
data,
"application/json",
authenticated=authenticated,
) as response:
return json.load(response)
def upload(self, release_id: int, file: Path) -> dict[str, Any]:
"""Stream multipart APK bytes instead of copying a universal APK into memory."""
boundary = uuid.uuid4().hex
prefix = (
f'--{boundary}\r\nContent-Disposition: form-data; name="attachment"; filename="{file.name}"\r\n'
"Content-Type: application/octet-stream\r\n\r\n"
).encode()
suffix = f"\r\n--{boundary}--\r\n".encode()
def chunks() -> Iterable[bytes]:
yield prefix
with file.open("rb") as stream:
while chunk := stream.read(1024 * 1024):
yield chunk
yield suffix
url = f"{self.api}/releases/{release_id}/assets?name={urllib.parse.quote(file.name)}"
with self.request(
"POST",
url,
chunks(),
f"multipart/form-data; boundary={boundary}",
len(prefix) + file.stat().st_size + len(suffix),
) as response:
return json.load(response)
def verify(self, url: str, expected: str, authenticated: bool) -> None:
"""Verify bytes fetched from Forgejo, not only its declared asset size."""
with self.request("GET", url, authenticated=authenticated) as response:
actual = hashlib.file_digest(response, "sha256").hexdigest()
if actual != expected:
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:
"""Read git's existing credential helper without secrets in argv, stdout or disk."""
parsed = urllib.parse.urlsplit(server)
environment = {
name: value
for name, value in os.environ.items()
if not name.startswith("GIT_TRACE")
and name not in {"GIT_CURL_VERBOSE", "SHELLOPTS", "BASHOPTS", "BASH_ENV", "ENV"}
}
environment["GIT_TERMINAL_PROMPT"] = "0"
result = subprocess.run(
["git", "credential", "fill"],
input=f"protocol={parsed.scheme}\nhost={parsed.netloc}\n\n",
text=True,
capture_output=True,
check=False,
env=environment,
)
if result.returncode:
raise RuntimeError("Git credential helper failed; no release created")
fields = dict(
line.split("=", 1) for line in result.stdout.splitlines() if "=" in line
)
if not fields.get("password"):
raise ValueError("Git credential helper returned no release credential")
encoded = base64.b64encode(
f"{fields.get('username', '')}:{fields['password']}".encode()
).decode()
return "Basic " + encoded
def validate_bundle(directory: Path) -> tuple[dict[str, Any], dict[str, str]]:
"""Require every public asset and verify the local final-byte checksum set."""
for name in REQUIRED:
if not (directory / name).is_file():
raise ValueError(f"Required release asset missing: {name}")
hashes: dict[str, str] = {}
for line in (directory / "SHA256SUMS").read_text().splitlines():
match = re.fullmatch(r"([0-9a-f]{64}) ([A-Za-z0-9_.-]+)", line)
if match is None or match[2] in hashes:
raise ValueError("Invalid or duplicate SHA256SUMS entry")
hashes[match[2]] = match[1]
if set(hashes) != REQUIRED - {"SHA256SUMS"}:
raise ValueError("Checksum set must cover exactly the public artifacts")
for name, expected in hashes.items():
with (directory / name).open("rb") as stream:
if hashlib.file_digest(stream, "sha256").hexdigest() != expected:
raise ValueError(f"Local checksum mismatch: {name}")
hashes["SHA256SUMS"] = hashlib.sha256(
(directory / "SHA256SUMS").read_bytes()
).hexdigest()
manifest = json.loads((directory / "wolfskin-update.json").read_text())
if (
manifest["schemaVersion"] != 1
or manifest["packageId"] != "org.justinvisser.wolfskin.tv.opt"
):
raise ValueError("Wrong household manifest identity")
if (
manifest["signerSha256"]
!= "182bac72e292b9ea4d3d4e6383f92eb2161fa28ba5afcff0337cb7d5a9d5341d"
):
raise ValueError("Wrong household signing authority")
version = manifest["versionName"]
code = manifest["versionCode"]
if (
not re.fullmatch(r"\d+\.\d+\.\d+", version)
or type(code) is not int
or not 1 <= code <= 2100000000
):
raise ValueError("Invalid household version identity")
if (
not re.fullmatch(r"[0-9a-f]{40}", manifest["sourceCommit"])
or manifest["sourceTag"] != f"pwolfskin-opt-v{version}"
):
raise ValueError("Invalid immutable source identity")
published = datetime.datetime.fromisoformat(
manifest["publishedAt"].replace("Z", "+00:00")
)
if published.utcoffset() is None:
raise ValueError("Publication time must include a timezone")
expected_abis = {"armeabi-v7a", "arm64-v8a", "x86_64", "universal"}
if (
len(manifest["assets"]) != 4
or {asset["abi"] for asset in manifest["assets"]} != expected_abis
):
raise ValueError("Manifest must describe all four standalone APKs")
for asset in manifest["assets"]:
expected_name = (
"Wolfskin.apk"
if asset["abi"] == "universal"
else f"Wolfskin-{asset['abi']}.apk"
)
if asset["name"] != expected_name or asset["minSdk"] < 23:
raise ValueError("Invalid APK asset identity or minimum SDK")
path = directory / asset["name"]
if (
hashes.get(path.name) != asset["sha256"]
or path.stat().st_size != asset["bytes"]
):
raise ValueError("Manifest does not match final APK bytes")
return manifest, hashes
def publish(
directory: Path,
server: str,
promote: bool,
dry_run: bool,
test_server: bool = False,
draft_id: int | None = None,
) -> None:
"""Keep incomplete uploads draft; withdraw a failed promoted release from latest."""
manifest, hashes = validate_bundle(directory)
version = manifest["versionName"]
tag = f"wolfskin-opt-v{version}"
if dry_run:
print(
f"Dry run: draft {REPOSITORY} {tag}; upload and verify {len(hashes)} assets; promote={promote}; no requests or credentials"
)
return
Forge(server, "", test_server).require_newer_code(manifest["versionCode"])
forge = Forge(server, credential(server), test_server)
if draft_id is None:
release = forge.json(
"POST",
"/releases",
{
"name": f"v{version}",
"tag_name": tag,
"body": (directory / "RELEASE-NOTES.md").read_text(),
"draft": True,
"prerelease": False,
},
)
else:
release = forge.json("GET", f"/releases/{draft_id}")
if not release["draft"] or release["tag_name"] != tag:
raise ValueError("Resume target must be the matching draft release")
release_id = release["id"]
existing = {asset["name"]: asset for asset in release.get("assets", [])}
print(
f"Verifying draft release {release_id}; it stays draft on any verification failure"
)
uploaded: dict[str, str] = {}
for name, expected in hashes.items():
asset = existing.get(name) or forge.upload(release_id, directory / name)
url = asset["browser_download_url"]
forge.verify(url, expected, authenticated=True)
uploaded[name] = url
if not promote:
print(
"Draft upload verified. Publication requires owner approval and explicit --promote."
)
return
forge.require_newer_code(manifest["versionCode"])
try:
forge.json("PATCH", f"/releases/{release_id}", {"draft": False})
latest = forge.json("GET", "/releases/latest", authenticated=False)
if latest["id"] != release_id or latest["tag_name"] != tag:
raise ValueError("Anonymous latest does not identify the promoted release")
for name, url in uploaded.items():
forge.verify(url, hashes[name], authenticated=False)
except (ValueError, RuntimeError, OSError, KeyError, TypeError):
try:
forge.json("PATCH", f"/releases/{release_id}", {"draft": True})
except (ValueError, RuntimeError, OSError):
raise RuntimeError(
"Post-publication verification AND withdrawal failed; release may remain public"
) from None
raise RuntimeError(
"Post-publication verification failed; release withdrawn to draft"
) from None
print(f"Published and anonymously verified {tag}")
def main() -> None:
"""Expose a deliberately separate publication command."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--directory", required=True, type=Path)
parser.add_argument("--promote", action="store_true")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument(
"--draft-id", type=int, help="Resume and reverify an existing matching draft"
)
parser.add_argument(
"--test-server", help="Local fake Forgejo only; HTTP loopback permitted"
)
args = parser.parse_args()
if args.test_server:
parsed = urllib.parse.urlsplit(args.test_server)
if parsed.scheme != "http" or parsed.hostname not in (
"localhost",
"127.0.0.1",
"::1",
):
parser.error("--test-server is restricted to an HTTP loopback fixture")
publish(
args.directory.resolve(),
args.test_server or SERVER,
args.promote,
args.dry_run,
bool(args.test_server),
args.draft_id,
)
if __name__ == "__main__":
main()

254
docker/tv_release.py Executable file
View file

@ -0,0 +1,254 @@
#!/usr/bin/env python3
"""Build and inspect the household assembly without creating a signing identity."""
import argparse
import datetime
import hashlib
import json
import os
import re
import shutil
import subprocess
import zipfile
from pathlib import Path
PACKAGE = "org.justinvisser.wolfskin.tv.opt"
SIGNER = "182bac72e292b9ea4d3d4e6383f92eb2161fa28ba5afcff0337cb7d5a9d5341d"
ABIS = ("armeabi-v7a", "arm64-v8a", "x86_64", "universal")
ROOT = Path(__file__).resolve().parent.parent
def run(*arguments: str) -> str:
"""Run a checked command whose arguments contain no credentials."""
return subprocess.check_output(arguments, cwd=ROOT, text=True).strip()
def digest(path: Path) -> str:
"""Hash final artifact bytes without loading an APK into memory."""
with path.open("rb") as stream:
return hashlib.file_digest(stream, "sha256").hexdigest()
def validate_inputs(version: str, code: int) -> str:
"""Require a clean immutable source and a code newer than the tracked history."""
if not re.fullmatch(r"\d+\.\d+\.\d+", version) or not 1 <= code <= 2100000000:
raise ValueError("Supply a plain VERSION and a positive Android VERSION_CODE")
if run("git", "status", "--porcelain"):
raise ValueError(
"Release source must be clean, including the version-code ledger"
)
commit = run("git", "rev-parse", "HEAD")
run(
"git",
"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):
raise ValueError(f"VERSION_CODE must exceed reserved maximum {max(used)}")
if list((ROOT / "app/libs").glob("*.aar")):
raise ValueError("Local AAR overrides are forbidden for household releases")
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:
"""Verify identity, final signature, optimization and playback-complete native inventory."""
image = os.environ.get("WOLFSKIN_BUILD_IMAGE", "wolfskin-build:sdk37-jdk21")
mount = f"type=bind,src={path.parent},dst=/artifacts,readonly"
base = (
"docker",
"run",
"--rm",
"--platform",
"linux/amd64",
"--mount",
mount,
image,
)
badging = run(*base, "aapt2", "dump", "badging", f"/artifacts/{path.name}")
certificate = run(
*base,
"apksigner",
"verify",
"--verbose",
"--print-certs",
f"/artifacts/{path.name}",
)
proof.write_text(badging + "\n" + certificate + "\n")
package = re.search(
r"package: name='([^']+)' versionCode='(\d+)' versionName='([^']+)'", badging
)
if package is None or package.groups() != (PACKAGE, str(code), version):
raise ValueError(f"Wrong APK identity: {path.name}")
fingerprints = re.findall(
r"Signer #\d+ certificate SHA-256 digest: ([0-9a-fA-F]+)", certificate
)
if fingerprints != [SIGNER]:
raise ValueError(f"Wrong household signer: {path.name}")
if "application-debuggable" in badging:
raise ValueError("Household APK must not be debuggable")
minimum = re.search(r"sdkVersion:'(\d+)'", badging)
if minimum is None:
raise ValueError("APK minimum SDK is missing")
with zipfile.ZipFile(path) as archive:
names = archive.namelist()
for profile in ("assets/dexopt/baseline.prof", "assets/dexopt/baseline.profm"):
if profile not in names:
raise ValueError(f"Missing optimized profile: {profile}")
if any(
name.startswith(("assets/wolfskin-", "assets/library/")) for name in names
):
raise ValueError("Debug fixtures leaked into the household assembly")
for target in ABIS[:-1] if abi == "universal" else (abi,):
libraries = [name for name in names if name.startswith(f"lib/{target}/")]
for component in ("mpv", "ffmpeg", "av1"):
if not any(component in name.lower() for name in libraries):
raise ValueError(f"Missing native {component} for {target}")
proof.with_suffix(".inventory").write_text("\n".join(names) + "\n")
return int(minimum.group(1))
def release(version: str, code: int, output: Path, dry_run: bool) -> None:
"""Produce named APKs, inspected metadata, checksums and source-linked release notes."""
commit = validate_inputs(version, code)
tag = f"pwolfskin-opt-v{version}"
key = Path(
os.environ.get(
"WOLFSKIN_DEBUG_KEYSTORE",
"/root/.treehouse/firstmate-7bab20/2/firstmate/data/signing/debug.keystore",
)
)
if not key.is_file() or key.stat().st_size == 0:
raise ValueError(
"Existing household signing key required; never generate a replacement"
)
if output.exists():
raise ValueError("Release output must not exist; preserve previous proof")
if dry_run:
print(
f"Dry run: source {commit}; defaultOptimized {version} code {code}; four APKs, manifest, notes and SHA256SUMS; no build, tag or publication"
)
return
existing = run("git", "tag", "--list", tag)
if existing and run("git", "rev-parse", f"{tag}^{{commit}}") != commit:
raise ValueError("Source tag already identifies another commit")
reserve_code(code, commit)
output.mkdir(parents=True)
private = output / "private"
private.mkdir(mode=0o700)
with (private / "build.log").open("w") as log:
subprocess.run(
[
"bash",
"docker/gradle.sh",
"household",
f"-PtvVersion={version}",
f"-PtvVersionCode={code}",
"assembleDefaultOptimized",
":app:dependencies",
"--configuration",
"defaultOptimizedRuntimeClasspath",
],
cwd=ROOT,
check=True,
stdout=log,
stderr=subprocess.STDOUT,
)
assets: list[dict[str, object]] = []
for abi in ABIS:
suffix = "" if abi == "universal" else f"-{abi}"
source = (
ROOT
/ f"app/build/outputs/apk/default/optimized/Wholphin-default-optimized-{version}-{code}{suffix}.apk"
)
target = output / f"Wolfskin{suffix}.apk"
shutil.copyfile(source, target)
minimum = inspect_apk(target, version, code, abi, private / f"{abi}.inspection")
assets.append(
{
"abi": abi,
"name": target.name,
"bytes": target.stat().st_size,
"sha256": digest(target),
"minSdk": minimum,
}
)
mapping = ROOT / "app/build/outputs/mapping/defaultOptimized"
shutil.copytree(mapping, private / "mapping")
manifest = {
"schemaVersion": 1,
"packageId": PACKAGE,
"versionName": version,
"versionCode": code,
"publishedAt": datetime.datetime.now(datetime.UTC)
.isoformat()
.replace("+00:00", "Z"),
"sourceCommit": commit,
"sourceTag": tag,
"signerSha256": SIGNER,
"assets": assets,
}
(output / "wolfskin-update.json").write_text(json.dumps(manifest, indent=2) + "\n")
source = f"https://git.justinvisser.org/justinzeus/wolfskin/src/tag/{tag}"
(output / "RELEASE-NOTES.md").write_text(
f"# Wolfskin TV {version}\n\nIn-app verified household updates with explicit Android confirmation.\n\n"
f"Package `{PACKAGE}`, code {code}, signer SHA-256 `{SIGNER}`.\n"
"Same-package, same-signer upgrades retain app data; never uninstall or clear data to update. "
"An earlier ephemeral signer needs a separately authorized migration, not an automatic reinstall.\n\n"
"Android 6 (API 23) or later. Standalone armeabi-v7a, arm64-v8a and x86_64 APKs; "
"universal fallback. 32-bit x86 is not MPV-complete and is unsupported for playback. "
"MPV, ffmpeg and AV1 extensions: pinned Wholphin extensions 0.2.4.\n\n"
f"GPL-2.0-only; Wolfskin derives from Wholphin by damontecres and contributors. "
f"Corresponding source: [{commit}]({source}); "
f"[source archive](https://git.justinvisser.org/justinzeus/wolfskin/archive/{tag}.tar.gz).\n"
"[Upstream Wholphin](https://github.com/damontecres/wholphin), "
"[native extension source 0.2.4](https://github.com/damontecres/wholphin-extensions/tree/v0.2.4).\n"
)
files = sorted(path for path in output.iterdir() if path.is_file())
(output / "SHA256SUMS").write_text(
"".join(f"{digest(path)} {path.name}\n" for path in files)
)
if not existing:
run("git", "tag", tag, commit)
with (ROOT / "docker/tv-version-codes").open("a") as ledger:
ledger.write(f"{code} {version} {commit}\n")
print(
f"Release packaged at {output}; commit the code ledger and push source tag {tag} before distribution. Nothing published."
)
def main() -> None:
"""Expose the make tv-release build contract."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", required=True)
parser.add_argument("--version-code", required=True, type=int)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
release(args.version, args.version_code, args.output.resolve(), args.dry_run)
if __name__ == "__main__":
main()

View file

@ -19,13 +19,18 @@ android {
}
kotlin.compilerOptions.jvmTarget = JvmTarget.JVM_11
val robolectricSdk by configurations.creating {
isCanBeResolved = true
isCanBeConsumed = false
isTransitive = false
}
val cacheRobolectricSdk by tasks.registering(Copy::class) {
from(robolectricSdk)
val versions = listOf(
"9-robolectric-4913185-2-i7",
"11-robolectric-6757853-i7",
"14-robolectric-10818077-i7",
)
// One configuration would evict the older SDKs through version conflict resolution.
from(versions.map { version ->
project.configurations.detachedConfiguration(
project.dependencies.create("org.robolectric:android-all-instrumented:$version"),
).apply { isTransitive = false }
})
into(layout.buildDirectory.dir("robolectric-sdk"))
}
@ -50,9 +55,6 @@ tasks.withType<Test>().configureEach {
}
dependencies {
robolectricSdk("org.robolectric:android-all-instrumented:9-robolectric-4913185-2-i7")
robolectricSdk("org.robolectric:android-all-instrumented:11-robolectric-6757853-i7")
robolectricSdk("org.robolectric:android-all-instrumented:14-robolectric-10818077-i7")
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.tv.material)

View file

@ -1,66 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import java.io.File
import org.junit.Assert.*
import org.junit.Test
/** The normal JVM gate rejects untokened styling, undersized typography and internal screen wording. */
class OwnedSourceAuditTest {
@Test fun ownedScreensUseTokensReadableTextAndViewerFacingNames() {
val roots = listOf(File("src/main/java"), File("../app/src/wolfskin"))
roots.forEach { assertTrue("Missing owned source root: $it", it.isDirectory) }
val violations = roots.flatMap { root ->
root.walkTopDown().filter { it.isFile && it.extension == "kt" }.flatMap { source ->
audit(source.name, source.readText()).map { "$source:$it" }
}.toList()
}
assertTrue(violations.joinToString("\n"), violations.isEmpty())
}
@Test fun plantedViolationsFailEachDoneBarRule() {
val cases = listOf(
Triple("Screen.kt", "Box(Modifier.width(12.dp))", "unit"),
Triple("Screen.kt", "Box(Modifier.width(12f.dp))", "unit"),
Triple("Screen.kt", "Text(fontSize = 14.sp)", "unit"),
Triple("Screen.kt", "Box(Modifier.width((width + gap).dp))", "unit"),
Triple("Screen.kt", "Text(color = Color(0xFF123456))", "color"),
Triple("Screen.kt", "Text(color = ComposeColor(red = 1f, green = 0f, blue = 0f))", "color"),
Triple("WolfskinTokens.kt", "val tiny = TextStyle(fontSize = 11.5.sp)", "font-floor"),
Triple("Screen.kt", "Text(\"Recs Movies\")", "wording"),
Triple("Screen.kt", "Text(\"DGiC\")", "wording"),
Triple("Screen.kt", "Text(\"Justin Shows\")", "wording"),
Triple("Screen.kt", "Text(\"Next page\")", "wording"),
Triple("Screen.kt", "Text(\"Previous page\")", "wording"),
Triple("Screen.kt", "Text(\"Page 2\")", "wording"),
)
cases.forEach { (name, source, rule) ->
assertTrue("$rule accepted $source", audit(name, source).any { it.endsWith(":$rule") })
}
assertTrue(audit("WolfskinTokens.kt", "val body = TextStyle(fontSize = 12.sp); val fill = Color(0xFF123456)").isEmpty())
assertTrue(audit("Screen.kt", "import androidx.compose.ui.unit.dp\nText(\"This title can’t be read\", style = WolfskinTokens.Type.body)").isEmpty())
}
private fun audit(name: String, source: String): List<String> {
val generated = name == "WolfskinTokens.kt"
return source.lineSequence().mapIndexedNotNull { index, line ->
val code = line.trimStart()
if (code.startsWith("import ") || code.startsWith("//") || code.startsWith("*") || code.startsWith("/*")) return@mapIndexedNotNull null
val rules = buildList {
if (!generated && units.containsMatchIn(line)) add("unit")
if (!generated && colors.containsMatchIn(line)) add("color")
if (fonts.findAll(line).any { it.groupValues[1].toDouble() < 12 }) add("font-floor")
if (strings.findAll(line).any { forbidden.containsMatchIn(it.value) }) add("wording")
}
rules.map { "${index + 1}:$it" }
}.flatten().toList()
}
companion object {
private val units = Regex("\\.(?:dp|sp)\\b")
private val colors = Regex("\\b(?:Compose)?Color\\s*\\(\\s*(?:0[xX]|[0-9.]|(?:red|green|blue|alpha|color)\\s*=)")
private val fonts = Regex("\\bfontSize\\s*=\\s*([0-9]+(?:\\.[0-9]+)?)[fF]?\\.sp\\b")
private val strings = Regex("\"(?:\\\\.|[^\"\\\\])*\"")
private val forbidden = Regex("Recs (?:Movies|Shows)|DGiC|(?:Justin|Mindel|Shared) (?:Movies|Shows)|(?:Next|Previous) page|Page (?:[0-9]|\\$)|\\bpagination\\b", RegexOption.IGNORE_CASE)
}
}