diff --git a/Makefile b/Makefile index df6bd824..dc06bb0c 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 98f03a3a..2da11b20 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 { 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 -> diff --git a/app/household-version.gradle.kts b/app/household-version.gradle.kts new file mode 100644 index 00000000..c9ca35d0 --- /dev/null +++ b/app/household-version.gradle.kts @@ -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=" } + 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() } + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 20fc0df0..198782d5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -36,6 +36,13 @@ + + + + + + + + + { val UpdateUrl = AppStringPreference( 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 } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt index 706285f2..fa702609 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt @@ -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, ) { 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 = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index 0e388213..cd9afc3e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -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, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt index a3bd52e4..67366b9f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt @@ -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() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt index 2fde3eae..e7ec173a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt @@ -52,6 +52,9 @@ class SwitchSeerrViewModel _state.update { it.copy(loading = LoadingState.Success) } } } + } else { + // Nothing to check so set success + _state.update { it.copy(loading = LoadingState.Success) } } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestUpdateChecker.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestUpdateChecker.kt index 2e84e2ca..925f5701 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestUpdateChecker.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestUpdateChecker.kt @@ -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 } diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/VersionCompareTests.kt b/app/src/test/java/com/github/damontecres/wholphin/test/VersionCompareTests.kt index fdd45e6a..93842728 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/VersionCompareTests.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/VersionCompareTests.kt @@ -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 diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/update/HouseholdUpdateControllerTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/update/HouseholdUpdateControllerTest.kt new file mode 100644 index 00000000..fc9c246b --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/update/HouseholdUpdateControllerTest.kt @@ -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>>() + 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) } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdateManifestTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdateManifestTest.kt new file mode 100644 index 00000000..947aace9 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdateManifestTest.kt @@ -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) } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdateSessionStreamTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdateSessionStreamTest.kt new file mode 100644 index 00000000..4f5f3159 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdateSessionStreamTest.kt @@ -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) + } + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/AndroidUpdatePlatform.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/AndroidUpdatePlatform.kt new file mode 100644 index 00000000..aa30e022 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/AndroidUpdatePlatform.kt @@ -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") + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/AndroidUpdateService.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/AndroidUpdateService.kt new file mode 100644 index 00000000..0410e22e --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/AndroidUpdateService.kt @@ -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, + 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 +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/ClassicUpdatePage.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/ClassicUpdatePage.kt new file mode 100644 index 00000000..1c61e943 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/ClassicUpdatePage.kt @@ -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(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) } + } + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/FakeUpdateController.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/FakeUpdateController.kt new file mode 100644 index 00000000..ebac04ed --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/FakeUpdateController.kt @@ -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() + + 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 } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/HouseholdUpdateController.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/HouseholdUpdateController.kt new file mode 100644 index 00000000..091be7a9 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/HouseholdUpdateController.kt @@ -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 + 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, + private val sdk: Int, +) : UpdateController { + private val mutableState = MutableStateFlow(UpdateUiState.Idle) + private val mutableVersion = MutableStateFlow(platform.installed().version) + private val mutableRelease = MutableStateFlow(null) + override val state = mutableState.asStateFlow() + override val installedVersion = mutableVersion.asStateFlow() + override val autoCheck = preferences.autoCheck + override val release = mutableRelease.asStateFlow() + private var candidate: UpdateCandidate? = null + private var ready: UpdateManifest? = null + private var operation: Job? = null + @Volatile private var installerActive = false + private var retryOperation: () -> Unit = { check(true) } + private val restored = scope.async { + 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 + } + } + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateChannel.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateChannel.kt new file mode 100644 index 00000000..6b0c12ab --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateChannel.kt @@ -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 +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateController.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateController.kt new file mode 100644 index 00000000..b46bb0e2 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateController.kt @@ -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 + val autoCheck: StateFlow + val installedVersion: StateFlow + val release: StateFlow + + fun check(manual: Boolean) + fun download() + /** Discard partial bytes; retain verified bytes and restore Ready without duplicating a system session. */ + fun cancel() + fun install() + fun retry() + 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, +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateInstallReceiver.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateInstallReceiver.kt new file mode 100644 index 00000000..497060af --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateInstallReceiver.kt @@ -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.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("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() + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateManifest.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateManifest.kt new file mode 100644 index 00000000..66a0240a --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateManifest.kt @@ -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, +) { + fun selectAsset(abis: List, 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(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) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateTransport.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateTransport.kt new file mode 100644 index 00000000..b6c83467 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateTransport.kt @@ -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() + + 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 { 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, 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, 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 } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/VerifiedUpdateStore.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/VerifiedUpdateStore.kt new file mode 100644 index 00000000..a28e80c1 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/VerifiedUpdateStore.kt @@ -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) + +/** 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, 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() + } + } +} diff --git a/docker/README.md b/docker/README.md index ec71b8e7..a4a688da 100644 --- a/docker/README.md +++ b/docker/README.md @@ -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`; the distribution tag is `wolfskin-opt-v`. +Before building, the code is consumed in the local Git ref +`refs/wolfskin-tv/version-codes/`. 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 --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` 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 @@ -185,6 +345,15 @@ not the working tree. An uncommitted merge still builds with the old version nam Neither an upstream merge nor its validation build authorizes a release or TV installation. +## Upstream v1.1.1 integration + +The patch release makes manual Seerr setup available when the optional companion +plugin is absent: setup is marked ready immediately rather than remaining pending. +With the plugin installed, an empty settings response or a failed settings read +still reaches the existing ready-state fallback; plugin URL prefill is unchanged. +There are no new Room migrations (schema remains 41), player, layout, session, +DNS/client, dependency, branding or signing changes relative to v1.1.0. + ## Wolfskin shell Wolfskin UI lives in `app/src/wolfskin/java`, beside upstream source. The signed-in @@ -949,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 @@ -1003,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 @@ -1035,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 ` 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 ` 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 diff --git a/docker/gradle.sh b/docker/gradle.sh index 461018bc..7249cf82 100755 --- a/docker/gradle.sh +++ b/docker/gradle.sh @@ -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 diff --git a/docker/test_tv_publish.py b/docker/test_tv_publish.py new file mode 100644 index 00000000..86ccce49 --- /dev/null +++ b/docker/test_tv_publish.py @@ -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() diff --git a/docker/test_tv_release.py b/docker/test_tv_release.py new file mode 100644 index 00000000..4a5d679a --- /dev/null +++ b/docker/test_tv_release.py @@ -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() diff --git a/docker/tv-version-codes b/docker/tv-version-codes new file mode 100644 index 00000000..63071ca8 --- /dev/null +++ b/docker/tv-version-codes @@ -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 diff --git a/docker/tv_publish.py b/docker/tv_publish.py new file mode 100755 index 00000000..7a1a131f --- /dev/null +++ b/docker/tv_publish.py @@ -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() diff --git a/docker/tv_release.py b/docker/tv_release.py new file mode 100755 index 00000000..5ce4b890 --- /dev/null +++ b/docker/tv_release.py @@ -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() diff --git a/wolfskin-design/build.gradle.kts b/wolfskin-design/build.gradle.kts index d1275edb..74caf761 100644 --- a/wolfskin-design/build.gradle.kts +++ b/wolfskin-design/build.gradle.kts @@ -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().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) diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/OwnedSourceAuditTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/OwnedSourceAuditTest.kt deleted file mode 100644 index ec217257..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/OwnedSourceAuditTest.kt +++ /dev/null @@ -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 { - 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) - } -}