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/README.md b/README.md index b8f04e12..7566ce5e 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,14 @@ Wolfskin is an Android TV client for one household. It is released through Forge The app uses Wolfskin launcher artwork and a lilac-on-`#07070a` theme by default on a fresh install. Existing theme choices and all upstream colour themes remain available under Settings → App theme. Settings → About credits and links to Wholphin by damontecres. +## Wolfskin TV updates + +Settings opens on Requests. Press Down, then OK to open Updates; the row and screen show the installed app version. Left/Right selects an action, Down reaches Auto-check, and Up returns to the remembered action. Release notes keep focus inside the notes panel: Up/Down scrolls, and Back returns to the Release notes button. + +Auto-check uses the same preference as classic settings and only checks for updates. Manual checks remain available when it is off. Download and Install update are separate, explicit actions; Android owns installation permission and confirmation. Returning from Android refreshes the installed version. Back can leave a download running, and returning to Updates shows the controller's current state. + +Classic settings (trial) is the final Settings row. It opens the full original settings, including About → Update and More/Advanced → Updates. All existing settings groups remain available. + --- # Wholphin - an OSS Android TV client for Jellyfin diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f2498e02..9b5190ad 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/com/github/damontecres/wholphin/ui/TestModule.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt index 3b4d3efa..8d73f34f 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt @@ -68,11 +68,16 @@ import javax.inject.Singleton @Module @TestInstallIn( components = [SingletonComponent::class], - replaces = [DeviceModule::class, AppModule::class], + replaces = [DeviceModule::class, AppModule::class, org.justinvisser.wolfskin.tv.update.UpdateModule::class], ) object TestModule { val testDispatcher = StandardTestDispatcher() + @Provides + @Singleton + fun updateController(): org.justinvisser.wolfskin.tv.update.UpdateController = + org.justinvisser.wolfskin.tv.update.FakeUpdateController() + @Provides @Singleton fun deviceInfo( diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt index 35e45892..ad3d4647 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt @@ -88,7 +88,7 @@ class AnimeAvailabilityTest { } } val model = WolfskinShellViewModel(context, session, mockk(), mockk(), mockk(), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) try { requireNotNull(model.onForeground(server, user)).join() assertEquals("$key with $count Anime titles", count > 0, "anime" in session.state!!.tabs) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt index 1af82b5c..125a67bd 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt @@ -69,7 +69,7 @@ class AnimeRootRemovalTest { val navigation = NavigationManager() val session = WolfskinSession() val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) try { model.load(server, user) requireNotNull(model.onForeground(server, user)).join() diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt index b748367e..052aa05c 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt @@ -49,7 +49,7 @@ class ConductarrAudienceRecoveryTest { val client = session.identity.value!!.client val libraries = LibraryReadFacade(mockk()) val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true)) + libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) try { requireNotNull(model.onForeground(server, user)).join() assertNull(libraries.audiences) @@ -82,7 +82,7 @@ class ConductarrAudienceRecoveryTest { session.authenticate() val libraries = LibraryReadFacade(mockk()) val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true)) + libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) try { val identity = session.identity.value!! val intent = org.justinvisser.wolfskin.design.DiscoverRequest( diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt index 07cd1e37..c412265e 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt @@ -91,7 +91,7 @@ class FirstSignInNavigationTest { val navigation = NavigationManager() val session = WolfskinSession() val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true), - LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) val home = HomeSnapshot(listOf(HomeRow("resume", HomeRowKind.Hero, cards = listOf( HomeTitle("episode", "Me and My Town", "episode", seriesTitle = "Desperate Housewives", season = 5, episode = 9)), heroKind = "resume"))) try { diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt index 95fcbb52..35c5cb57 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt @@ -85,7 +85,7 @@ class PersonPickerSessionTest { val navigation = remember { NavigationManager() } val model = remember { WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)).also { models.add(it) } + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()).also { models.add(it) } } DisposableEffect(model) { onDispose { model.viewModelScope.cancel() } } WolfskinApp(server, current.user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt index 6a5936f8..9a1ff11e 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt @@ -87,7 +87,7 @@ class PersonSwitchThreadingTest { coEvery { libraries.readDestinations(any()) } returns emptyMap() val session = WolfskinSession() val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) val clients = mutableListOf() try { model.load(server, justin) @@ -163,7 +163,7 @@ class PersonSwitchThreadingTest { coEvery { libraries.readDestinations(any()) } returns emptyMap() val session = WolfskinSession() val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) try { model.load(server, justin) requireNotNull(model.onForeground(server, justin)).join() diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt index b7eb73de..4c195596 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt @@ -63,7 +63,7 @@ class SavedPeoplePickerTest { val session = WolfskinSession() val navigation = NavigationManager() val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true), - mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) try { rule.setContent { WolfskinApp(server, users.first(), navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt index dbcffdb4..8a01861d 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt @@ -66,7 +66,7 @@ class ShellStartupTimingTest { coEvery { libraries.readDestinations(user.id) } returns emptyMap() val session = WolfskinSession() val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) val load = launch { model.load(server, user) } try { withTimeout(5_000) { savedPeopleStarted.await() } @@ -121,7 +121,7 @@ class ShellStartupTimingTest { coEvery { libraries.readDestinations(any()) } returns emptyMap() val session = WolfskinSession() val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) val pending = CompletableDeferred() val release = java.util.concurrent.CountDownLatch(1) val firstAudience = java.util.concurrent.atomic.AtomicBoolean(true) 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/UpdateBehaviorTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdateBehaviorTest.kt new file mode 100644 index 00000000..3f6b81f3 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdateBehaviorTest.kt @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv.update + +import org.junit.Assert.* +import org.junit.Test +import org.justinvisser.wolfskin.design.ShellKey + +/** Behavior boundaries are controller outcomes, not incidental UI wording. */ +class UpdateBehaviorTest { + @Test fun everyOutcomeHasOnlyExplicitUserActions() { + val expected = listOf( + UpdateUiState.Idle to listOf(UpdateCommand.Check), + UpdateUiState.Checking to listOf(UpdateCommand.Back), + UpdateUiState.UpToDate to listOf(UpdateCommand.Check), + UpdateUiState.NoReleasePublished to listOf(UpdateCommand.Check), + updateReleaseFixture() to listOf(UpdateCommand.Download, UpdateCommand.Notes), + UpdateUiState.Downloading(12, 30) to listOf(UpdateCommand.Cancel), + UpdateUiState.Ready("1.1.1") to listOf(UpdateCommand.Install, UpdateCommand.Notes), + UpdateUiState.PermissionNeeded to listOf(UpdateCommand.Install, UpdateCommand.Cancel), + UpdateUiState.HandingOff to listOf(UpdateCommand.Cancel), + UpdateUiState.InstallCancelled to listOf(UpdateCommand.Install, UpdateCommand.Notes), + UpdateUiState.Offline to listOf(UpdateCommand.Retry), + UpdateUiState.NoInstaller to listOf(UpdateCommand.Retry, UpdateCommand.Classic), + ) + UpdateFailure.entries.map { UpdateUiState.Failed(it, "operation failed", UpdateOperation.Download) to listOf(UpdateCommand.Retry, UpdateCommand.Back) } + expected.forEach { (state, commands) -> + assertEquals(state.toString(), commands, presentUpdate(state, updateReleaseFixture()).actions.map { it.command }) + } + assertNotEquals(presentUpdate(UpdateUiState.UpToDate, null), presentUpdate(UpdateUiState.NoReleasePublished, null)) + } + + @Test fun refreshedInstalledIdentityDoesNotRequireOrMaskAChannelCheck() { + val installed = "1.1.1" + val refreshed = presentUpdate(UpdateUiState.Idle, null, installed) + assertNotEquals(presentUpdate(UpdateUiState.Idle, null), refreshed) + assertEquals(listOf(UpdateCommand.Check), refreshed.actions.map { it.command }) + assertEquals(presentUpdate(UpdateUiState.Checking, null), + presentUpdate(UpdateUiState.Checking, null, installed)) + } + + @Test fun percentageRequiresPositiveTotalAndIsBounded() { + listOf(null, 0L, -1L).forEach { total -> + val ui = presentUpdate(UpdateUiState.Downloading(20_100_000, total), updateReleaseFixture()) + assertNull(ui.fraction) + } + assertEquals(.42f, presentUpdate(UpdateUiState.Downloading(20_076_000, 47_800_000), null).fraction!!, .0001f) + assertEquals(1f, presentUpdate(UpdateUiState.Downloading(90, 30), null).fraction!!, 0f) + } + + @Test fun actionsAndAutoCheckRememberFocusWithoutTogglingOnMovement() { + val controller = FakeUpdateController(updateReleaseFixture()) + val focus = UpdateFocus(controller, {}, {}, {}, {}) + focus.update(controller.state.value, presentUpdate(controller.state.value, controller.release.value).actions) + focus.key(ShellKey.Right) + focus.key(ShellKey.Down) + assertTrue(focus.autoFocused) + assertTrue(controller.autoCheck.value) + focus.key(ShellKey.Ok) + assertFalse(controller.autoCheck.value) + focus.key(ShellKey.Up) + assertFalse(focus.autoFocused) + assertEquals(1, focus.actionIndex) + assertFalse(controller.autoCheck.value) + } + + @Test fun notesTrapFocusAndBackRestoresNotesAction() { + val controller = FakeUpdateController(updateReleaseFixture()) + var backs = 0 + var navs = 0 + val steps = mutableListOf() + val focus = UpdateFocus(controller, { backs++ }, {}, { navs++ }, steps::add) + focus.update(controller.state.value, presentUpdate(controller.state.value, controller.release.value).actions) + focus.key(ShellKey.Right); focus.key(ShellKey.Ok) + listOf(ShellKey.Up, ShellKey.Down, ShellKey.Left, ShellKey.Right, ShellKey.Ok).forEach(focus::key) + assertTrue(focus.notesOpen) + assertEquals(listOf(-1, 1), steps) + assertEquals(0, navs) + assertTrue(controller.autoCheck.value) + focus.key(ShellKey.Back) + assertFalse(focus.notesOpen) + assertEquals(1, focus.actionIndex) + assertEquals(0, backs) + focus.key(ShellKey.Up) + assertEquals(1, navs) + focus.key(ShellKey.Back) + assertEquals(1, backs) + } + + @Test fun progressUpdatesCannotStealFocusAndInstallerCancellationRestoresInstall() { + val controller = FakeUpdateController(UpdateUiState.Downloading(1, 100)) + val focus = UpdateFocus(controller, {}, {}, {}, {}) + fun update(state: UpdateUiState) { focus.update(state, presentUpdate(state, null).actions) } + update(controller.state.value) + focus.key(ShellKey.Down) + update(UpdateUiState.Downloading(90, 100)) + assertTrue(focus.autoFocused) + update(UpdateUiState.Ready("1.1.1")) + assertFalse(focus.autoFocused) + focus.key(ShellKey.Right) + update(UpdateUiState.PermissionNeeded) + update(UpdateUiState.InstallCancelled) + assertEquals(0, focus.actionIndex) + assertFalse(focus.autoFocused) + } +} + +/** Same release facts as the approved television frames. */ +fun updateReleaseFixture() = UpdateUiState.Available("1.1.1", 47_800_000, "6 Nov 2026", + "Updates inside Wolfskin settings.\nKeep sign-ins and preferences.\nClassic settings remains for a trial.") 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/test/java/org/justinvisser/wolfskin/tv/update/UpdatesKeysTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdatesKeysTest.kt new file mode 100644 index 00000000..e593675d --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdatesKeysTest.kt @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv.update + +import android.app.Application +import android.view.View +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.semantics.SemanticsProperties +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.design.* +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Native D-pad behavior preserves the retained shell, action focus and real scroller. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class UpdatesKeysTest { + @get:Rule val rule = createAndroidComposeRule() + private val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin").apply { + pickerVisible = false + enterDestination("updates") + settingsRow = 1 + } + private lateinit var controller: FakeUpdateController + private val reduced = mutableStateOf(false) + private val directory get() = System.getProperty("wolfskin.updater.captureDirectory")?.let(::File) + + @Before fun fullscreen() { + rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) + @Suppress("DEPRECATION") + rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + rule.mainClock.autoAdvance = false + } + + private fun launch(state: UpdateUiState, auto: Boolean = true, settings: Boolean = false) { + controller = FakeUpdateController(state, auto, InstalledVersion("1.1.0-140-g5162c299", 100000), state as? UpdateUiState.Available ?: updateReleaseFixture()) + if (settings) shell.enterDestination("settings") + rule.setContent { + CompositionLocalProvider(LocalReducedMotion provides reduced.value) { + WolfskinTheme { + ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), + settingsInstalledVersion = controller.installedVersion.value.versionName) { modifier -> + UpdatesScreen(shell, controller, { shell.navFocused = false; shell.enterDestination("settings") }, {}, modifier) + } + } + } + } + rule.mainClock.advanceTimeBy(1000) + rule.waitForIdle() + } + + private fun capture(name: String) { + val target = directory ?: return + target.mkdirs() + rule.waitForIdle() + val file = File(target, "$name.png") + file.parentFile?.mkdirs() + rule.onRoot().captureRoboImage(file.absolutePath) + } + + private fun press(keys: List) { + keys.forEach { key -> + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } + rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } + rule.mainClock.advanceTimeByFrame() + } + rule.mainClock.advanceTimeBy(320) + rule.waitForIdle() + } + + @Test fun classicRequiresEveryRetainedRowAndTheRealScroller() { + shell.settingsRow = 0 + launch(UpdateUiState.Checking, settings = true) + repeat(9) { press(listOf(Key.DirectionDown)) } + assertEquals(9, shell.settingsRow) + assertEquals(4, shell.view.listState.firstVisibleItemIndex) + capture("classic") + press(listOf(Key.DirectionCenter)) + assertEquals("classic", shell.handOff) + } + + @Test fun notesTrapRealKeysAndBackPreservesSettingsRowAndScroller() { + launch(updateReleaseFixture()) + press(listOf(Key.DirectionRight, Key.DirectionCenter, Key.DirectionUp, Key.DirectionRight, Key.DirectionDown)) + assertTrue(shell.contentOverlayVisible) + assertFalse(shell.navFocused) + assertTrue(controller.autoCheck.value) + press(listOf(Key.Back)) + assertFalse(shell.contentOverlayVisible) + assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex) + press(listOf(Key.Back)) + assertEquals("settings", shell.route) + assertEquals(1, shell.settingsRow) + assertEquals(0, shell.view.listState.firstVisibleItemIndex) + } + + @Test fun longNotesScrollWithoutEscapingToNavOrAutoCheck() { + launch(updateReleaseFixture().copy(notes = (1..30).joinToString("\n") { "Release detail $it" })) + press(listOf(Key.DirectionRight, Key.DirectionCenter, Key.DirectionDown, Key.DirectionDown)) + val range = rule.onNodeWithTag("update-notes").fetchSemanticsNode().config[SemanticsProperties.VerticalScrollAxisRange] + assertTrue(range.value() > 0f) + assertFalse(shell.navFocused) + assertFalse((shell.contentFocus as UpdateFocus).autoFocused) + press(listOf(Key.Back)) + assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex) + } + + @Test fun navAndAutoCheckReturnToTheRememberedActionWithoutToggling() { + launch(updateReleaseFixture()) + press(listOf(Key.DirectionRight, Key.DirectionDown, Key.DirectionUp)) + assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex) + assertTrue(controller.autoCheck.value) + press(listOf(Key.DirectionUp)) + assertTrue(shell.navFocused) + press(listOf(Key.DirectionDown)) + assertFalse(shell.navFocused) + assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex) + assertTrue(controller.autoCheck.value) + } + + @Test fun refreshingInstalledIdentityCannotMaskAnOngoingCheck() { + launch(UpdateUiState.UpToDate) + rule.runOnIdle { controller.showInstalled(InstalledVersion("1.1.1", 100001)) } + rule.mainClock.advanceTimeBy(320) + rule.runOnIdle { controller.show(UpdateUiState.Checking) } + rule.mainClock.advanceTimeBy(320) + rule.onNodeWithTag("update-progress").assertIsDisplayed() + } + + @Test fun progressKeepsAutoCheckFocusAcrossByteAndReducedMotionChanges() { + launch(UpdateUiState.Checking) + val target = directory + if (target != null) { + repeat(113) { frame -> + rule.mainClock.advanceTimeBy(16) + capture("motion/checking-%03d".format(frame)) + } + } + rule.runOnIdle { controller.show(UpdateUiState.Downloading(20_100_000, 47_800_000)) } + rule.mainClock.advanceTimeByFrame() + repeat(24) { frame -> rule.mainClock.advanceTimeBy(16); capture("motion/state-%03d".format(frame)) } + if (target != null) { + repeat(101) { frame -> rule.mainClock.advanceTimeBy(16); capture("motion/download-%03d".format(frame)) } + } + press(listOf(Key.DirectionDown)) + rule.runOnIdle { controller.show(UpdateUiState.Downloading(31_000_000, 47_800_000)) } + rule.mainClock.advanceTimeBy(16) + assertTrue((shell.contentFocus as UpdateFocus).autoFocused) + rule.runOnIdle { reduced.value = true } + rule.mainClock.advanceTimeBy(96) + capture("motion/reduced-000") + rule.mainClock.advanceTimeBy(1600) + capture("motion/reduced-100") + assertTrue((shell.contentFocus as UpdateFocus).autoFocused) + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index 1eeef88d..7e39a508 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -79,6 +79,7 @@ class WolfskinShellViewModel @Inject constructor( val seeAll: WolfskinSeeAllLists, val conductarr: ConductarrSession, artwork: WolfskinArtwork, + val updates: org.justinvisser.wolfskin.tv.update.UpdateController, ) : ViewModel() { init { artwork.configure(context) } val backdropFlow: StateFlow = backdrop.backdropFlow @@ -194,7 +195,7 @@ class WolfskinShellViewModel @Inject constructor( is ShellAction.Destination -> { val target = if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route) when { - action.route == "settings" -> navigation.navigateTo(target) + action.route == "settings" || action.route == "updates" -> navigation.navigateTo(target) action.preservePushed && navigation.backStack.size > 1 -> navigation.backStack[0] = target else -> navigation.replace(target) } @@ -266,6 +267,7 @@ fun WolfskinApp( { shell, homeServer, homeUser, contentModifier -> HomeDestination(shell, homeServer, homeUser, contentModifier) }, ) { val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle() + val installedVersion by viewModel.updates.installedVersion.collectAsStateWithLifecycle() LaunchedEffect(server.id, user.id, user.accessToken, ConductarrPreference.baseUrl, ConductarrPreference.fixtures) { viewModel.load(server, user) } androidx.lifecycle.compose.LifecycleEventEffect(androidx.lifecycle.Lifecycle.Event.ON_RESUME) { viewModel.onForeground(server, user) } LaunchedEffect(identity?.signedIn, identity?.person) { @@ -310,7 +312,13 @@ fun WolfskinApp( onBack = onBack, entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()), entryProvider = { destination -> - NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") { + NavEntry( + destination, + contentKey = "${destination}_${server.id}_${user.id}", + metadata = if (destination is Destination.Wolfskin && destination.route == "updates") { + org.justinvisser.wolfskin.tv.update.updatesRouteTransitions + } else emptyMap(), + ) { // Cached entries must not retain the launch shell after sign-in completes. val state = LocalShellState.current val discover = LocalDiscoverState.current @@ -331,11 +339,15 @@ fun WolfskinApp( } else TitleDestination(route, state, preferences) } else { ShellSurface(state, handlesContentKeys = upstream == null, + settingsInstalledVersion = installedVersion.versionName, overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier -> when { (upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier) upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier) destination is Destination.Home -> homeContent(state, server, user, contentModifier) + route == "updates" -> org.justinvisser.wolfskin.tv.update.UpdatesScreen(state, viewModel.updates, + onBack = { state.navFocused = false; navigationManager.goBack() }, + onClassic = { navigationManager.navigateTo(handOffDestination("classic")) }, modifier = contentModifier) route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") -> WolfskinLibraryContent(androidx.compose.ui.platform.LocalContext.current, route, state, user, "${server.id}:${user.id}", viewModel.libraries, navigationManager, viewModel.seeAll, contentModifier) destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier) 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..75803a95 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateController.kt @@ -0,0 +1,53 @@ +// 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 + /** Candidate facts survive download/installer transitions and verified-file restoration. */ + 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/UpdateFocus.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateFocus.kt new file mode 100644 index 00000000..cd959f76 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateFocus.kt @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv.update + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import org.justinvisser.wolfskin.design.ShellContentFocus +import org.justinvisser.wolfskin.design.ShellKey + +/** Action focus survives progress emissions; release notes own every key until Back. */ +class UpdateFocus( + private val controller: UpdateController, + private val back: () -> Unit, + private val classic: () -> Unit, + private val nav: () -> Unit, + private val scrollNotes: (Int) -> Unit, +) : ShellContentFocus { + override val cardKey: String? = null + var actionIndex by mutableIntStateOf(0) + private set + var autoFocused by mutableStateOf(false) + private set + var notesOpen by mutableStateOf(false) + private set + private var actions = emptyList() + private var previousState: Class<*>? = null + + /** Only a changed operation may retire an action; progress cannot steal focus. */ + fun update(state: UpdateUiState, value: List) { + if (previousState != state.javaClass) { + actionIndex = 0 + notesOpen = false + if (state == UpdateUiState.InstallCancelled || state is UpdateUiState.Ready) autoFocused = false + } + previousState = state.javaClass + actions = value + actionIndex = actionIndex.coerceAtMost(actions.lastIndex.coerceAtLeast(0)) + } + + fun focusAction(index: Int) { actionIndex = index; autoFocused = false } + + fun activate(index: Int) { + focusAction(index) + when (actions[index].command) { + UpdateCommand.Back -> back() + UpdateCommand.Check -> controller.check(manual = true) + UpdateCommand.Download -> controller.download() + UpdateCommand.Notes -> notesOpen = true + UpdateCommand.Cancel -> controller.cancel() + UpdateCommand.Install -> controller.install() + UpdateCommand.Retry -> controller.retry() + UpdateCommand.Classic -> classic() + } + } + + fun toggleAuto() { autoFocused = true; controller.setAutoCheck(!controller.autoCheck.value) } + + override fun key(key: ShellKey): Boolean { + if (notesOpen) { + when (key) { + ShellKey.Back -> notesOpen = false + ShellKey.Up -> scrollNotes(-1) + ShellKey.Down -> scrollNotes(1) + else -> Unit + } + return true + } + when (key) { + ShellKey.Back -> back() + ShellKey.Up -> if (autoFocused) autoFocused = false else nav() + ShellKey.Down -> autoFocused = true + ShellKey.Left -> if (!autoFocused) actionIndex = (actionIndex - 1).coerceAtLeast(0) + ShellKey.Right -> if (!autoFocused) actionIndex = (actionIndex + 1).coerceAtMost(actions.lastIndex.coerceAtLeast(0)) + ShellKey.Ok -> if (autoFocused) toggleAuto() else if (actions.isNotEmpty()) activate(actionIndex) + } + return true + } +} 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/UpdateNavigation.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateNavigation.kt new file mode 100644 index 00000000..ec5de195 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateNavigation.kt @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv.update + +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.togetherWith +import androidx.navigation3.ui.NavDisplay + +/** Updates animates bounded content only; the retained nav never crossfades as a full scene. */ +internal val updatesRouteTransitions = + NavDisplay.transitionSpec { (EnterTransition.None togetherWith ExitTransition.None).using(null) } + + NavDisplay.popTransitionSpec { (EnterTransition.None togetherWith ExitTransition.None).using(null) } + + NavDisplay.predictivePopTransitionSpec { (EnterTransition.None togetherWith ExitTransition.None).using(null) } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdatePresentation.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdatePresentation.kt new file mode 100644 index 00000000..a052fac5 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdatePresentation.kt @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv.update + +import java.text.NumberFormat + +/** Screen commands remain explicit; no state transition implicitly installs an APK. */ +enum class UpdateCommand { Back, Check, Download, Notes, Cancel, Install, Retry, Classic } + +/** An action is a user command, not a focusable progress indicator. */ +data class UpdateAction(val label: String, val command: UpdateCommand, val primary: Boolean = false) + +/** Plain copy for the process controller's state; bytes and notes remain real controller data. */ +data class UpdatePresentation( + val title: String = "", + val summary: String = "", + val paragraphs: List = emptyList(), + val actions: List, + val progress: Boolean = false, + val fraction: Float? = null, + val byteProgress: String? = null, +) + +/** Decimal megabytes match release sizes; unknown totals never create a percentage. */ +fun formatUpdateBytes(bytes: Long): String = NumberFormat.getNumberInstance().apply { + minimumFractionDigits = 1 + maximumFractionDigits = 1 +}.format(bytes / 1_000_000.0) + " MB" + +/** Metadata uses the controller's locale-formatted release date. */ +fun updateMetadata(release: UpdateUiState.Available?): String = release?.let { + "${it.versionName} · ${formatUpdateBytes(it.bytes)} · ${it.dateText}" +}.orEmpty() + +/** Every controller outcome is distinct from a successful compatible version comparison. */ +fun presentUpdate(state: UpdateUiState, release: UpdateUiState.Available?, updatedVersion: String? = null): UpdatePresentation { + val back = UpdateAction("Back", UpdateCommand.Back) + val notes = UpdateAction("Release notes", UpdateCommand.Notes) + val retry = UpdateAction("Retry", UpdateCommand.Retry, true) + val metadata = updateMetadata(release) + return when (state) { + UpdateUiState.Idle -> updatedVersion?.let(::updatedPresentation) + ?: UpdatePresentation(actions = listOf(UpdateAction("Check for updates", UpdateCommand.Check, true))) + UpdateUiState.Checking -> UpdatePresentation(actions = listOf(back), progress = true) + UpdateUiState.UpToDate -> updatedVersion?.let(::updatedPresentation) + ?: UpdatePresentation("Up to date", "No newer release for this TV.", + actions = listOf(UpdateAction("Check again", UpdateCommand.Check, true))) + UpdateUiState.NoReleasePublished -> UpdatePresentation("No release published", "No update has been published for this TV yet.", + actions = listOf(UpdateAction("Check again", UpdateCommand.Check, true))) + is UpdateUiState.Available -> UpdatePresentation("Update available", updateMetadata(state), + state.notes.lineSequence().filter { it.isNotBlank() }.take(3).toList(), + listOf(UpdateAction("Download", UpdateCommand.Download, true), notes)) + is UpdateUiState.Downloading -> { + val total = state.bytesTotal?.takeIf { it > 0 } + val fraction = total?.let { (state.bytesDone.toDouble() / it).coerceIn(0.0, 1.0).toFloat() } + val bytes = total?.let { "${formatUpdateBytes(state.bytesDone).removeSuffix(" MB")} / ${formatUpdateBytes(it)}" } + ?: formatUpdateBytes(state.bytesDone) + UpdatePresentation(fraction?.let { "${(it * 100).toInt()}%" }.orEmpty(), metadata, + actions = listOf(UpdateAction("Cancel download", UpdateCommand.Cancel)), progress = true, fraction = fraction, byteProgress = bytes) + } + is UpdateUiState.Ready -> UpdatePresentation("Ready to install", metadata.ifEmpty { state.versionName }, + listOf("Verified · your sign-ins and settings stay."), listOf(UpdateAction("Install update", UpdateCommand.Install, true), notes)) + UpdateUiState.PermissionNeeded -> UpdatePresentation("Allow updates on this TV", "Enable Wolfskin in Install unknown apps, then return.", + actions = listOf(UpdateAction("Open TV settings", UpdateCommand.Install, true), UpdateAction("Not now", UpdateCommand.Cancel))) + UpdateUiState.HandingOff -> UpdatePresentation("Continue in Android", "Confirm the update on the system screen.", + listOf("Wolfskin closes during installation.", "Choose Open, or reopen Wolfskin from the launcher."), listOf(UpdateAction("Back", UpdateCommand.Cancel))) + UpdateUiState.InstallCancelled -> UpdatePresentation("Install cancelled", metadata, + listOf("Verified · your sign-ins and settings stay."), listOf(UpdateAction("Try install again", UpdateCommand.Install, true), notes)) + is UpdateUiState.Failed -> UpdatePresentation(failureTitle(state.kind, state.operation), state.message, actions = listOf(retry, back)) + UpdateUiState.Offline -> UpdatePresentation("No network", "Reconnect this TV, then try again.", + actions = listOf(UpdateAction("Try again", UpdateCommand.Retry, true))) + UpdateUiState.NoInstaller -> UpdatePresentation("This TV cannot open an installer", "Android’s install confirmation is unavailable.", + listOf("The download is kept. Classic settings may offer the same system limitation."), + listOf(retry, UpdateAction("Classic settings (trial)", UpdateCommand.Classic))) + } +} + +private fun updatedPresentation(version: String) = UpdatePresentation( + "Updated to $version", "Sign-ins and settings kept.", + actions = listOf(UpdateAction("Check again", UpdateCommand.Check, true)), +) + +private fun failureTitle(kind: UpdateFailure, operation: UpdateOperation): String { + if (operation == UpdateOperation.Install) return "Install failed" + return when (kind) { + UpdateFailure.Network, UpdateFailure.HttpRefused, UpdateFailure.InsufficientStorage -> + if (operation == UpdateOperation.Check) "Check failed" else "Download failed" + UpdateFailure.InvalidMetadata -> + if (operation == UpdateOperation.Check) "Update unavailable" else "Verification failed" + UpdateFailure.UnsupportedAbi -> "Update unavailable" + UpdateFailure.ChecksumMismatch, UpdateFailure.WrongPackage, UpdateFailure.WrongSigner, UpdateFailure.LowerVersionCode -> "Verification failed" + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateProgress.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateProgress.kt new file mode 100644 index 00000000..fc23963c --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateProgress.kt @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv.update + +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.semantics +import org.justinvisser.wolfskin.design.reducedMotion +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M +import org.justinvisser.wolfskin.design.WolfskinTokens.Update as U + +/** Draw-only bounded track: changing bytes never moves logical or Android focus. */ +@Composable +fun UpdateProgress(fraction: Float?, modifier: Modifier = Modifier) { + val reduced = reducedMotion() + val phase = if (reduced) 0f else { + val transition = rememberInfiniteTransition(label = "update-progress") + val animated by transition.animateFloat(0f, 1f, + infiniteRepeatable(tween(if (fraction == null) M.updateScan else M.updateSheen, + easing = if (fraction == null) M.breatheEase else M.linear), RepeatMode.Restart), label = "highlight") + animated + } + Canvas(modifier.fillMaxWidth().height(U.progressHeight).testTag("update-progress") + .semantics { progressBarRangeInfo = fraction?.let { ProgressBarRangeInfo(it, 0f..1f) } ?: ProgressBarRangeInfo.Indeterminate }) { + val radius = CornerRadius(U.progressRadius.toPx()) + drawRoundRect(C.surface3, cornerRadius = radius) + val width = size.width * (fraction ?: U.scanFraction) + val left = if (fraction != null || reduced) 0f else width * (-1f + (U.scanEnd + 1f) * phase) + clipRect { + drawRoundRect(C.accent, Offset(left, 0f), Size(width, size.height), radius) + if (!reduced && fraction != null && width > 0f) { + clipRect(0f, 0f, width, size.height) { + val start = width * (-1f + 2f * phase) + drawRect(Brush.horizontalGradient(listOf(Color.Transparent, Color.White.copy(alpha = U.sheenAlpha), Color.Transparent), + startX = start, endX = start + width), size = Size(width, size.height)) + } + } + } + } +} 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/UpdatesScreen.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdatesScreen.kt new file mode 100644 index 00000000..0db04aa7 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdatesScreen.kt @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv.update + +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.tv.material3.Text +import kotlinx.coroutines.launch +import org.justinvisser.wolfskin.design.Avatar +import org.justinvisser.wolfskin.design.CssColumn +import org.justinvisser.wolfskin.design.PickerButton +import org.justinvisser.wolfskin.design.ShellState +import org.justinvisser.wolfskin.design.WolfskinTokens +import org.justinvisser.wolfskin.design.exactLineBox +import org.justinvisser.wolfskin.design.focusScale +import org.justinvisser.wolfskin.design.motionDuration +import org.justinvisser.wolfskin.design.reducedMotion +import org.justinvisser.wolfskin.design.resolveUpdateAvatarColor +import org.justinvisser.wolfskin.design.rowReveal +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L +import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M +import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S +import org.justinvisser.wolfskin.design.UpdateTypography as T +import org.justinvisser.wolfskin.design.WolfskinTokens.Update as U + +/** Controller lifetime belongs to the process, while navigation owns this screen's focus. */ +@Composable +fun UpdatesScreen(shell: ShellState, controller: UpdateController, onBack: () -> Unit, onClassic: () -> Unit, modifier: Modifier = Modifier) { + val state by controller.state.collectAsStateWithLifecycle() + val installed by controller.installedVersion.collectAsStateWithLifecycle() + val release by controller.release.collectAsStateWithLifecycle() + val autoCheck by controller.autoCheck.collectAsStateWithLifecycle() + val initialInstalledCode = remember { installed.versionCode } + val updatedVersion = installed.versionName.takeIf { installed.versionCode > initialInstalledCode } + val presentation = remember(state, release, updatedVersion) { + presentUpdate(state, release, updatedVersion.takeIf { state == UpdateUiState.Idle || state == UpdateUiState.UpToDate }) + } + val scroll = rememberScrollState() + val scope = rememberCoroutineScope() + val density = LocalDensity.current + val reduced = reducedMotion() + val scrollDuration by rememberUpdatedState(motionDuration(M.scroll, reduced)) + val focus = remember(controller, shell) { + UpdateFocus(controller, onBack, onClassic, { shell.navFocused = true; shell.navIndex = shell.currentTab }) { direction -> + scope.launch { scroll.animateScrollBy(with(density) { U.notesScrollStep.toPx() } * direction, + tween(scrollDuration, easing = M.easeOut)) } + } + } + LaunchedEffect(state, presentation.actions) { focus.update(state, presentation.actions) } + LaunchedEffect(controller) { if (controller.state.value == UpdateUiState.Idle) controller.check(manual = true) } + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { controller.onResume() } + DisposableEffect(shell, focus) { + shell.contentFocus = focus + onDispose { if (shell.contentFocus === focus) shell.contentFocus = null; shell.contentOverlayVisible = false } + } + LaunchedEffect(focus.notesOpen) { shell.contentOverlayVisible = focus.notesOpen } + Box(modifier.fillMaxSize().testTag("updates-screen")) { + Row(Modifier.offset(L.safeSide, U.headerTop).height(U.headerHeight), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsHeaderGap)) { + Avatar(shell.person, S.settingsHeaderAvatar, T.avatarLetterLibHeader, letterColor = C.muted, backgroundColor = resolveUpdateAvatarColor(shell.person)) + Text("Updates", Modifier.alignByBaseline().exactLineBox(T.screenTitle), style = T.screenTitle, color = C.fg) + Text("Installed ${installed.versionName}", Modifier.alignByBaseline(), style = T.nav.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Normal), color = C.muted) + } + Text("Back · Settings", Modifier.align(Alignment.TopEnd).padding(end = L.safeSide).offset(y = U.backTop), style = T.label.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Normal), color = C.muted) + if (focus.notesOpen) { + NotesPanel(release, scroll) + } else { + UpdatePanel(presentation, focus, !shell.navFocused, state.javaClass) + AutoCheckRow(autoCheck, focus.autoFocused && !shell.navFocused, Modifier.offset(L.safeSide, U.autoTop), focus::toggleAuto) + } + } +} + +@Composable +private fun UpdatePanel(presentation: UpdatePresentation, focus: UpdateFocus, focused: Boolean, identity: Any) { + val shape = RoundedCornerShape(U.radius) + CssColumn(Modifier.offset(L.safeSide, U.contentTop).width(U.width).rowReveal(identity) + .background(C.surface, shape).border(U.border, C.border, shape).padding(U.padding + U.border)) { + if (presentation.title.isNotEmpty()) { + Text(presentation.title, Modifier.exactLineBox(T.heading), style = T.heading, color = C.fg) + Spacer(Modifier.height(U.titleBottom)) + } + if (presentation.summary.isNotEmpty()) { + Text(presentation.summary, Modifier.exactLineBox(T.body), style = T.body, color = C.muted) + if (presentation.paragraphs.isNotEmpty()) Spacer(Modifier.height(U.paragraphBottom)) + } + presentation.paragraphs.forEachIndexed { index, paragraph -> + Text(paragraph, Modifier.exactLineBox(T.body), style = T.body, color = C.muted) + if (index != presentation.paragraphs.lastIndex) Spacer(Modifier.height(U.paragraphBottom)) + } + if (presentation.progress) { + Spacer(Modifier.height(U.progressTop)) + UpdateProgress(presentation.fraction) + presentation.byteProgress?.let { + Spacer(Modifier.height(U.progressBottom)) + Text(it, Modifier.exactLineBox(T.body), style = T.body, color = C.muted) + } + } + Row(Modifier.padding(top = U.actionsTop), horizontalArrangement = Arrangement.spacedBy(U.actionGap)) { + presentation.actions.forEachIndexed { index, action -> + Box(Modifier.testTag("update-action-${action.command}")) { + PickerButton(action.label, focused && !focus.autoFocused && focus.actionIndex == index, action.primary, + textStyle = T.button, onFocus = { focus.focusAction(index) }) { focus.activate(index) } + } + } + } + } +} + +@Composable +private fun AutoCheckRow(enabled: Boolean, focused: Boolean, modifier: Modifier, onToggle: () -> Unit) { + val shape = RoundedCornerShape(WolfskinTokens.Radius.listRow) + Row(modifier.size(L.settingsRowWidth, L.settingsRowHeight).testTag("update-auto-check") + .semantics { role = Role.Switch; stateDescription = if (enabled) "On" else "Off" } + .focusScale(focused, WolfskinTokens.Focus.scaleRow) + .background(if (focused) C.fg else C.listRowRest, shape).clickable(onClick = onToggle) + .padding(horizontal = S.settingsRowPadding), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("Auto-check for updates", Modifier.exactLineBox(T.listTitle), style = T.listTitle, color = if (focused) C.onFocus else C.fg) + Text("${if (enabled) "On" else "Off"} · checks only, never installs", Modifier.exactLineBox(T.caption), style = T.caption, color = if (focused) C.onFocus else C.muted) + } + Box(Modifier.size(U.toggleWidth, U.toggleHeight).background(if (focused || !enabled) C.surface3 else C.accentSoft, CircleShape)) { + Box(Modifier.align(if (enabled) Alignment.CenterEnd else Alignment.CenterStart).padding(horizontal = U.toggleInset) + .size(U.toggleThumb).background(if (enabled) C.accentText else C.muted, CircleShape)) + } + } +} + +@Composable +private fun NotesPanel(release: UpdateUiState.Available?, scroll: androidx.compose.foundation.ScrollState) { + val shape = RoundedCornerShape(U.radius) + val paragraphs = remember(release?.notes) { release?.notes?.lineSequence()?.filter { it.isNotBlank() }?.toList().orEmpty() } + val notesStyle = T.nav.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Normal) + CssColumn(Modifier.offset(L.safeSide, U.contentTop).size(U.width, U.notesHeight).testTag("update-notes") + .drawWithCache { + val ring = WolfskinTokens.Focus.ringControlRing.toPx() / 2f + val radius = U.radius.toPx() + val inset = U.border.toPx() + ring / 2f + onDrawWithContent { + drawContent() + drawRoundRect(C.accent, Offset(-ring / 2f, -ring / 2f), Size(size.width + ring, size.height + ring), + CornerRadius(radius + ring / 2f), style = Stroke(ring)) + drawRoundRect(C.accent, Offset(inset, inset), Size(size.width - inset * 2, size.height - inset * 2), + CornerRadius(radius - inset), style = Stroke(ring)) + } + } + .background(C.surface, shape).border(U.border, C.border, shape) + .verticalScroll(scroll).padding(U.padding + U.border)) { + Text(updateMetadata(release), Modifier.exactLineBox(T.stateTitle), style = T.stateTitle.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold), color = C.fg) + Spacer(Modifier.height(U.notesListTop)) + paragraphs.forEachIndexed { index, paragraph -> + Row(Modifier.padding(start = U.notesMarkerOffset).exactLineBox(notesStyle)) { + Text("•", Modifier.width(U.notesMarkerWidth).exactLineBox(notesStyle), style = notesStyle, color = C.muted) + Text(paragraph.removePrefix("- "), Modifier.exactLineBox(notesStyle), style = notesStyle, color = C.muted) + } + if (index != paragraphs.lastIndex) Spacer(Modifier.height(U.notesGap)) + } + Spacer(Modifier.height(U.notesListBottom)) + Text("Sign-in stays on this TV. Android asks before installing.", Modifier.exactLineBox(T.body), style = T.body, color = C.muted) + } +} 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 72f7bf1d..4cabb7f6 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 @@ -95,20 +252,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 @@ -121,7 +280,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 @@ -191,6 +351,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 @@ -1070,14 +1239,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 @@ -1124,21 +1291,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 @@ -1156,9 +1327,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 9969c0ef..eb68b7ba 100644 --- a/wolfskin-design/build.gradle.kts +++ b/wolfskin-design/build.gradle.kts @@ -19,18 +19,24 @@ 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", + "16-robolectric-13921718-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")) } val checkTokenDrift by tasks.registering { - inputs.files(fileTree(projectDir) { include("tokens.json", "shell-tokens.json", "title-tokens.json", "home-tokens.json", "discover-tokens.json", "generate_tokens.py", "tokens.css", "tokens.sha256", "src/main/java/**/WolfskinTokens.kt") }) + inputs.files(fileTree(projectDir) { include("tokens.json", "shell-tokens.json", "title-tokens.json", "home-tokens.json", "discover-tokens.json", "update-tokens.json", "generate_tokens.py", "tokens.css", "tokens.sha256", "src/main/java/**/WolfskinTokens.kt") }) doLast { file("tokens.sha256").readLines().forEach { line -> val (expected, name) = line.split(" ", limit = 2) @@ -50,10 +56,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") - robolectricSdk("org.robolectric:android-all-instrumented:16-robolectric-13921718-i7") implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.ui) implementation(libs.androidx.tv.material) diff --git a/wolfskin-design/generate_tokens.py b/wolfskin-design/generate_tokens.py index e18bc364..d036e496 100755 --- a/wolfskin-design/generate_tokens.py +++ b/wolfskin-design/generate_tokens.py @@ -121,9 +121,10 @@ def main() -> None: tokens.update(json.loads((ROOT / "title-tokens.json").read_text())) tokens.update(json.loads((ROOT / "home-tokens.json").read_text())) tokens.update(json.loads((ROOT / "discover-tokens.json").read_text())) + tokens.update(json.loads((ROOT / "update-tokens.json").read_text())) kotlin, css = render_tokens(tokens) outputs = {str(KOTLIN): kotlin, "tokens.css": css} - inputs = ["tokens.json", "shell-tokens.json", "title-tokens.json", "home-tokens.json", "discover-tokens.json", "generate_tokens.py"] + inputs = ["tokens.json", "shell-tokens.json", "title-tokens.json", "home-tokens.json", "discover-tokens.json", "update-tokens.json", "generate_tokens.py"] checksums = {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in inputs} checksums.update({name: hashlib.sha256(content.encode()).hexdigest() for name, content in outputs.items()}) outputs["tokens.sha256"] = "".join(f"{checksum} {name}\n" for name, checksum in sorted(checksums.items())) diff --git a/wolfskin-design/src/main/assets/update-font-license.txt b/wolfskin-design/src/main/assets/update-font-license.txt new file mode 100644 index 00000000..272739c0 --- /dev/null +++ b/wolfskin-design/src/main/assets/update-font-license.txt @@ -0,0 +1,47 @@ +DejaVu Sans and DejaVu Sans Bold +https://dejavu-fonts.github.io/ + +Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. +Bitstream Vera is a trademark of Bitstream, Inc. +DejaVu changes are in public domain. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of the fonts accompanying this license ("Fonts") and associated +documentation files (the "Font Software"), to reproduce and distribute the +Font Software, including without limitation the rights to use, copy, merge, +publish, distribute, and/or sell copies of the Font Software, and to permit +persons to whom the Font Software is furnished to do so, subject to the +following conditions: + +The above copyright and trademark notices and this permission notice shall +be included in all copies of one or more of the Font Software typefaces. + +The Font Software may be modified, altered, or added to, and in particular +the designs of glyphs or characters in the Fonts may be modified and +additional glyphs or characters may be added to the Fonts, only if the fonts +are renamed to names not containing either the words "Bitstream" or the word +"Vera". + +This License becomes null and void to the extent applicable to Fonts or Font +Software that has been modified and is distributed under the "Bitstream +Vera" names. + +The Font Software may be sold as part of a larger software package but no +copy of one or more of the Font Software typefaces may be sold by itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, +TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME +FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING +ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF +THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE +FONT SOFTWARE. + +Except as contained in this notice, the names of Gnome, the Gnome +Foundation, and Bitstream Inc., shall not be used in advertising or +otherwise to promote the sale, use or other dealings in this Font Software +without prior written authorization from the Gnome Foundation or Bitstream +Inc., respectively. For further information, contact: fonts at gnome dot +org. diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CssLineBox.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CssLineBox.kt index 4f1d281a..1600e51c 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CssLineBox.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CssLineBox.kt @@ -40,7 +40,7 @@ private class CssColumnHeight : ParentDataModifier { /** Fractional line heights accumulate before rounding, rather than adding a rounded pixel per row. */ @Composable -internal fun CssColumn(modifier: Modifier = Modifier, gap: Dp = WolfskinTokens.Shell.zero, content: @Composable () -> Unit) { +fun CssColumn(modifier: Modifier = Modifier, gap: Dp = WolfskinTokens.Shell.zero, content: @Composable () -> Unit) { val exact = remember { CssColumnHeight() } Layout(content, modifier.then(exact)) { children, constraints -> val measured = Array(children.size) { children[it].measure(constraints.copy(minWidth = 0, minHeight = 0)) } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Glyph.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Glyph.kt index 5a9227d3..0039ab4a 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Glyph.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Glyph.kt @@ -11,6 +11,9 @@ import androidx.tv.material3.Icon fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) { val resource = when (name) { "gear" -> R.drawable.wolfskin_gear + "updates" -> R.drawable.wolfskin_update + "settings-list" -> R.drawable.wolfskin_settings_list + "settings-right" -> R.drawable.wolfskin_settings_chevron "requests" -> R.drawable.wolfskin_tray "interface" -> R.drawable.wolfskin_grid "play", "playback" -> R.drawable.wolfskin_play diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Identity.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Identity.kt index 266cbded..6aec4bf2 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Identity.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Identity.kt @@ -21,19 +21,24 @@ fun personColor(key: String): Color = when (key.lowercase()) { else -> WolfskinTokens.Color.accent } +/** Updates has its own locked avatar swatch; other destinations retain household colours. */ +fun resolveUpdateAvatarColor(person: ShellPerson): Color = + if (person.key.equals("justin", ignoreCase = true)) WolfskinTokens.Update.avatarJustin else personColor(person.key) + /** The same mark anchors the picker and the persistent navigation. */ @Composable -fun Wordmark(modifier: Modifier = Modifier) { +fun Wordmark(modifier: Modifier = Modifier, prefix: String = "Wolf", textStyle: TextStyle = WolfskinTokens.Type.wordmark) { Row(modifier) { - Text("Wolf", color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.wordmark) - Text("skin", color = WolfskinTokens.Color.accent, style = WolfskinTokens.Type.wordmark) + Text(prefix, color = WolfskinTokens.Color.fg, style = textStyle) + Text("skin", color = WolfskinTokens.Color.accent, style = textStyle) } } /** Initials are drawn over opaque person colour at the exact slot size. */ @Composable -fun Avatar(person: ShellPerson, diameter: Dp, textStyle: TextStyle, modifier: Modifier = Modifier, letterColor: Color = WolfskinTokens.Color.onFocus) { - Box(modifier.size(diameter).background(personColor(person.key), CircleShape), contentAlignment = Alignment.Center) { +fun Avatar(person: ShellPerson, diameter: Dp, textStyle: TextStyle, modifier: Modifier = Modifier, letterColor: Color = WolfskinTokens.Color.onFocus, + backgroundColor: Color = personColor(person.key)) { + Box(modifier.size(diameter).background(backgroundColor, CircleShape), contentAlignment = Alignment.Center) { Text(person.initial, color = letterColor, style = textStyle) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt index 40b8563c..17b0af02 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt @@ -40,15 +40,16 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.SavedIndicator as V /** Blob bounds retain their measurement while their draw coordinates spring between targets. */ data class NavBox(val left: Dp, val top: Dp, val width: Dp, val height: Dp) -fun navBoxes(tabs: List): List { - var left = L.safeSide + S.wordmarkWidth + L.wordmarkGap + L.navItemGap +fun navBoxes(tabs: List, isUpdatesLayout: Boolean = false): List { + val updates = WolfskinTokens.Update + var left = L.safeSide + (if (isUpdatesLayout) updates.wordmarkWidth else S.wordmarkWidth) + L.wordmarkGap + L.navItemGap return tabs.map { tab -> val width = when (tab) { - "home" -> S.navHomeWidth - "discover" -> S.navDiscoverWidth - "movies" -> S.navMoviesWidth - "shows" -> S.navShowsWidth - else -> S.navAnimeWidth + "home" -> if (isUpdatesLayout) updates.navHomeWidth else S.navHomeWidth + "discover" -> if (isUpdatesLayout) updates.navDiscoverWidth else S.navDiscoverWidth + "movies" -> if (isUpdatesLayout) updates.navMoviesWidth else S.navMoviesWidth + "shows" -> if (isUpdatesLayout) updates.navShowsWidth else S.navShowsWidth + else -> if (isUpdatesLayout) updates.navAnimeWidth else S.navAnimeWidth } NavBox(left, S.navItemTop, width, L.navItemHeight).also { left += width + L.navItemGap } } + NavBox(S.stageWidth - L.safeSide - L.navAvatar, S.navItemTop, L.navAvatar, L.navAvatar) @@ -57,7 +58,8 @@ fun navBoxes(tabs: List): List { /** There is one blob, including when focus wraps around the nav avatar. */ @Composable fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) { - val boxes = remember(state.person.hasAnime) { navBoxes(state.tabs) } + val hasSelectedTab = state.route != "settings" && state.route != "updates" + val boxes = remember(state.person.hasAnime, hasSelectedTab) { navBoxes(state.tabs, isUpdatesLayout = !hasSelectedTab) } val reduced = reducedMotion() val labelFade = tween(motionDuration(S.navLabelFade, reduced), easing = CubicBezierEasing(0f, 0f, .58f, 1f)) val index = if (state.navFocused) state.navIndex else state.currentTab @@ -79,14 +81,15 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) Box(modifier.fillMaxWidth().height(L.navBand)) { Canvas(Modifier.matchParentSize()) { if (scrolled) drawRect(C.bg) else drawRect(band) - drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2)) + if (state.navFocused || hasSelectedTab) drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2)) } - Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop)) + Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop), if (hasSelectedTab) "Wolf" else "wolf", + if (hasSelectedTab) WolfskinTokens.Type.wordmark else UpdateTypography.wordmark) state.tabs.forEachIndexed { itemIndex, tab -> val item = boxes[itemIndex] val focused = state.navFocused && state.navIndex == itemIndex val labelColor by animateColorAsState( - if (focused) C.onFocus else if (itemIndex == state.currentTab) C.fg else C.muted, + if (focused) C.onFocus else if (hasSelectedTab && itemIndex == state.currentTab) C.fg else C.muted, animationSpec = labelFade, label = "nav-label-$tab", ) @@ -95,8 +98,8 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) .clickable { state.focusNav(itemIndex); state.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) { - Text(tab.replaceFirstChar { it.uppercase() }, style = WolfskinTokens.Type.nav, color = labelColor) - if (itemIndex == state.currentTab && !focused) { + Text(tab.replaceFirstChar { it.uppercase() }, style = if (hasSelectedTab) WolfskinTokens.Type.nav else UpdateTypography.nav, color = labelColor) + if (hasSelectedTab && itemIndex == state.currentTab && !focused) { Box(Modifier.align(Alignment.BottomCenter).offset(y = -L.navUnderline).size(item.width - L.navItemPaddingX * 2, L.navUnderline).background(C.accent)) } } @@ -111,10 +114,11 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) } } val avatar = boxes.last() - Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav, + Avatar(state.person, L.navAvatar, if (hasSelectedTab) WolfskinTokens.Type.avatarLetterNav else UpdateTypography.avatarLetterNav, Modifier.offset(avatar.left, avatar.top) .focusRing(avatarFocused, L.navAvatar / 2, WolfskinTokens.Focus.ringAvatarRing) .focusProperties { canFocus = false } - .clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) }) + .clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) }, + backgroundColor = if (hasSelectedTab) personColor(state.person.key) else resolveUpdateAvatarColor(state.person)) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt index 08c79cd8..fe7adad1 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt @@ -9,13 +9,12 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape @@ -36,50 +35,53 @@ import androidx.tv.material3.Text import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S +import org.justinvisser.wolfskin.design.UpdateTypography as T /** Settings routes identify real upstream destinations rather than stand-in panels. */ -data class SettingsGroup(val key: String, val title: String, val description: String, val wolfskin: Boolean = false) +data class SettingsGroup(val key: String, val title: String, val description: String) val SETTINGS_GROUPS = listOf( - SettingsGroup("requests", "Requests", "Coming in: what the household asked for, and how far each is"), - SettingsGroup("interface", "Interface", "Sign in automatically · Remember selected tabs · Theme music · Subtitle style · Application theme · Screensaver"), - SettingsGroup("playback", "Playback", "Skip forward · Skip back · Skip back when resuming"), - SettingsGroup("nextup", "Next up", "Show next up timing · Auto play next up · Delay · Passout protection"), - SettingsGroup("profile", "User profile settings", "Profile protection · Customize home page (Wolfskin presets: Default, Compact, Series thumbs, Episode thumbnails) · Navigation drawer items · Language", true), - SettingsGroup("about", "About", "Installed version · Update (Wolfskin releases on Forgejo) · Wolfskin about", true), - SettingsGroup("seerr", "Seerr integration", "Add, change or remove the Seerr server this TV asks for Discover and ratings", true), - SettingsGroup("advanced", "Advanced settings", "Ratings display · Backdrop style · Playback backend (ExoPlayer, MPV) · Max resolution and bitrate · Skip segments · Logs and image cache"), + SettingsGroup("requests", "Requests", "Coming in: what the household asked for"), + SettingsGroup("updates", "Updates", "Check and install"), + SettingsGroup("interface", "Interface", "Sign-in · Theme music · Subtitles"), + SettingsGroup("playback", "Playback", "Skip forward · Skip back · Resume"), + SettingsGroup("nextup", "Next up", "Auto-play · Delay · Passout protection"), + SettingsGroup("profile", "User profile settings", "Protection · Home · Language"), + SettingsGroup("about", "About", "Installed version · Wolfskin about"), + SettingsGroup("seerr", "Seerr integration", "Manage the Seerr server"), + SettingsGroup("advanced", "Advanced settings", "Playback backend · Logs · Image cache"), + SettingsGroup("classic", "Classic settings (trial)", "Original settings, including About → Update"), ) /** The count describes returned request history, not an inferred queue of active downloads. */ @Composable -fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null) { +fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null, installedVersion: String = "") { val list = state.view.listState + val reduced = reducedMotion() val density = LocalDensity.current LaunchedEffect(state.settingsRow, state.navFocused) { if (state.navFocused) return@LaunchedEffect - val y = L.row_lhead + L.row_srow * state.settingsRow - val target = if (y >= L.windowAhead) y - L.pinnedLine else S.zero - val current = if (list.firstVisibleItemIndex == 0) S.zero else L.row_lhead + L.row_srow * (list.firstVisibleItemIndex - 1) - val distance = with(density) { (target - current).toPx() } - list.firstVisibleItemScrollOffset - list.animateScrollBy(distance, tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut)) + val target = (state.settingsRow - WolfskinTokens.Update.settingsVisibleRows.toInt() + 1).coerceAtLeast(0) + val distance = with(density) { (L.row_srow * (target - list.firstVisibleItemIndex)).toPx() } - list.firstVisibleItemScrollOffset + list.animateScrollBy(distance, tween(if (reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut)) } - LazyColumn(modifier.fillMaxSize(), state = list, - contentPadding = PaddingValues(bottom = S.stageHeight - L.pinnedLine - L.row_srow)) { - item(key = "settings-header") { - Row(Modifier.height(L.row_lhead).padding(start = L.safeSide, top = S.settingsHeaderTop), - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsHeaderGap)) { - Avatar(state.person, S.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader, letterColor = C.muted) - Text("Settings", Modifier.alignByBaseline(), style = WolfskinTokens.Type.screenTitle, color = C.fg) - Text("Requests and Wholphin settings on this TV · ${state.person.name}", Modifier.alignByBaseline(), style = WolfskinTokens.Type.nav.copy(fontWeight = FontWeight.Normal), color = C.muted) - } + Box(modifier.fillMaxSize()) { + Row(Modifier.offset(L.safeSide, WolfskinTokens.Update.headerTop).height(WolfskinTokens.Update.headerHeight), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsHeaderGap)) { + Avatar(state.person, S.settingsHeaderAvatar, T.avatarLetterLibHeader, letterColor = C.muted, backgroundColor = resolveUpdateAvatarColor(state.person)) + Text("Settings", Modifier.alignByBaseline().exactLineBox(T.screenTitle), style = T.screenTitle, color = C.fg) + Text("This TV · ${state.person.name}", Modifier.alignByBaseline(), style = T.nav.copy(fontWeight = FontWeight.Normal), color = C.muted) } - itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group -> - Box(Modifier.height(L.row_srow)) { - SettingsRow(group, !state.navFocused && !state.requestsVisible && index == state.settingsRow, - if (group.key == "requests") requestCount else null, - Modifier.offset(L.safeSide, S.settingsRowTop)) { - state.navFocused = false; state.settingsRow = index; state.key(ShellKey.Ok) + LazyColumn(Modifier.fillMaxWidth().offset(y = WolfskinTokens.Update.rowsTop) + .height(WolfskinTokens.Update.rowsViewport + L.settingsRowHeight * (WolfskinTokens.Focus.scaleRow - 1f)), + state = list, contentPadding = PaddingValues(bottom = S.settingsRowGap)) { + itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group -> + Box(Modifier.height(L.row_srow)) { + val display = if (group.key == "updates") group.copy(description = "$installedVersion · Check and install") else group + SettingsRow(display, !state.navFocused && !state.requestsVisible && index == state.settingsRow, + if (group.key == "requests") requestCount else null, Modifier.offset(L.safeSide)) { + state.navFocused = false; state.settingsRow = index; state.key(ShellKey.Ok) + } } } } @@ -94,15 +96,14 @@ fun SettingsRow(group: SettingsGroup, focused: Boolean, requestCount: Int?, modi .background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow)) .clickable(onClick = onClick).padding(horizontal = S.settingsRowPadding), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsRowGap)) { - Glyph(group.key, if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon)) + Glyph(if (group.key == "updates") "updates" else "settings-list", if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon)) Column(Modifier.weight(1f)) { val count = requestCount?.let { " · $it ${if (it == 1) "request" else "requests"}" } ?: "" - Text(group.title + count, Modifier.exactLineBox(WolfskinTokens.Type.listTitle), color = foreground, style = WolfskinTokens.Type.listTitle, maxLines = 1) - Text(group.description, Modifier.exactLineBox(WolfskinTokens.Type.caption), color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(group.title + count, Modifier.exactLineBox(T.listTitle), color = foreground, style = T.listTitle, maxLines = 1) + Text(group.description, Modifier.exactLineBox(T.caption), color = if (focused) C.onFocus else C.muted, style = T.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) } - if (group.wolfskin) SettingsPill("Wolfskin", focused) - SettingsPill(if (group.key == "requests") "Wolfskin screen" else "upstream screen", focused) - Glyph("right", if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsChevron)) + SettingsPill(if (group.key == "requests" || group.key == "updates") "Wolfskin screen" else "upstream screen", focused) + Glyph("settings-right", if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon)) } } @@ -122,6 +123,6 @@ private fun SettingsPill(label: String, focused: Boolean) { drawRoundRect(border, topLeft = inset, size = bounds, cornerRadius = radius, style = outline) } }.padding(horizontal = S.settingsPillPadding + S.settingsPillStroke), contentAlignment = Alignment.Center) { - Text(label, color = foreground, style = WolfskinTokens.Type.caption.copy(fontWeight = WolfskinTokens.Type.label.fontWeight)) + Text(label, color = foreground, style = T.caption.copy(fontWeight = T.label.fontWeight)) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt index b18715bd..3499479c 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt @@ -265,9 +265,13 @@ class ShellState( } } else if (route == "settings") { val group = SETTINGS_GROUPS[settingsRow].key - if (group == "requests") openRequests() else { - handOff = group - onAction(ShellAction.HandOff(group)) + when (group) { + "requests" -> openRequests() + "updates" -> onAction(ShellAction.Destination("updates")) + else -> { + handOff = group + onAction(ShellAction.HandOff(group)) + } } } else contentFocus?.key(ShellKey.Ok) @@ -309,6 +313,7 @@ class ShellState( pickerLaunch = false gearFocused = true } else if (route !in tabs) { + if (route == "updates") navFocused = false onAction(ShellAction.Pop) } else if (!navFocused) { navFocused = true diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt index ec44774d..c6dc38b1 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt @@ -56,6 +56,7 @@ fun ShellSurface( modifier: Modifier = Modifier, handlesContentKeys: Boolean = true, settingsRequestCount: Int? = null, + settingsInstalledVersion: String = "", overlay: @Composable () -> Unit = {}, content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) }, ) { @@ -95,7 +96,7 @@ fun ShellSurface( if (state.pickerVisible) { WhosWatching(state) } else { - if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup()) + if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount, installedVersion = settingsInstalledVersion) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup()) NavBar(state, view.listState.firstVisibleItemIndex > 0 || view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2) if (state.requestsVisible) ComingInSheet(state) overlay() diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/UpdateTypography.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/UpdateTypography.kt new file mode 100644 index 00000000..e777372e --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/UpdateTypography.kt @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import androidx.compose.ui.text.PlatformTextStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight + +/** The approved Updates frames resolve system-ui to DejaVu Sans; other screens keep their fonts. */ +object UpdateTypography { + val family = FontFamily(Font(R.font.update_dejavu_sans), Font(R.font.update_dejavu_sans_bold, FontWeight.Bold)) + private val lineBox = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) + private val platform = PlatformTextStyle(includeFontPadding = false) + private fun resolveStyle(style: TextStyle) = style.copy(fontFamily = family, lineHeightStyle = lineBox, platformStyle = platform) + val screenTitle = resolveStyle(WolfskinTokens.Type.screenTitle) + val stateTitle = resolveStyle(WolfskinTokens.Type.stateTitle) + val heading = resolveStyle(WolfskinTokens.Update.heading) + val nav = resolveStyle(WolfskinTokens.Type.nav) + val wordmark = resolveStyle(WolfskinTokens.Type.wordmark) + val listTitle = resolveStyle(WolfskinTokens.Type.listTitle) + val body = resolveStyle(WolfskinTokens.Type.body) + val button = resolveStyle(WolfskinTokens.Type.button) + val label = resolveStyle(WolfskinTokens.Type.label) + val caption = resolveStyle(WolfskinTokens.Type.caption) + val avatarLetterNav = resolveStyle(WolfskinTokens.Type.avatarLetterNav) + val avatarLetterLibHeader = resolveStyle(WolfskinTokens.Type.avatarLetterLibHeader) +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt index 0e12d430..c927d9c9 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt @@ -221,6 +221,9 @@ object WolfskinTokens { const val fillInverse = "background color.fg, text color.onFocus" } object Motion { + const val updateScan = 1800 + const val updateSheen = 1600 + val linear = CubicBezierEasing(0.0f, 0.0f, 1.0f, 1.0f) const val skeletonCycle = 1400 const val skeletonLowAlpha = 0.55f const val focusMove = 180 @@ -703,4 +706,48 @@ object WolfskinTokens { const val ambientSaturation = 1.2f const val ambientBrightness = 0.55f } + object Update { + val avatarJustin = ComposeColor(0xFF7AB8FF) + val wordmarkWidth = 81.796875.dp + val navHomeWidth = 74.6640625.dp + val navDiscoverWidth = 99.0703125.dp + val navMoviesWidth = 85.2578125.dp + val navShowsWidth = 80.578125.dp + val navAnimeWidth = 79.234375.dp + val notesListTop = 14.0.dp + val notesListBottom = 14.0.dp + val notesMarkerOffset = 5.5.dp + val notesMarkerWidth = 14.5.dp + val heading = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight(800), lineHeight = 20.160000000000004.sp, letterSpacing = -0.02.em) + const val settingsVisibleRows = 6.0f + val headerTop = 75.0.dp + val headerHeight = 42.0.dp + val rowsTop = 139.0.dp + val rowsViewport = 362.0.dp + val contentTop = 149.0.dp + val width = 650.0.dp + val padding = 20.0.dp + val border = 1.0.dp + val radius = 11.0.dp + val titleBottom = 8.0.dp + val paragraphBottom = 7.0.dp + val actionsTop = 18.0.dp + val actionGap = 12.0.dp + val progressTop = 15.0.dp + val progressBottom = 11.0.dp + val progressHeight = 6.0.dp + val progressRadius = 4.0.dp + val autoTop = 431.0.dp + val toggleWidth = 40.0.dp + val toggleHeight = 21.0.dp + val toggleThumb = 15.0.dp + val toggleInset = 3.0.dp + val notesHeight = 256.0.dp + val notesGap = 9.0.dp + val notesScrollStep = 40.0.dp + val backTop = 92.0.dp + const val scanFraction = 0.28f + const val scanEnd = 4.6f + const val sheenAlpha = 0.35f + } } diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_settings_chevron.xml b/wolfskin-design/src/main/res/drawable/wolfskin_settings_chevron.xml new file mode 100644 index 00000000..bbf83f40 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_settings_chevron.xml @@ -0,0 +1,3 @@ + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_settings_list.xml b/wolfskin-design/src/main/res/drawable/wolfskin_settings_list.xml new file mode 100644 index 00000000..362cc295 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_settings_list.xml @@ -0,0 +1,3 @@ + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_update.xml b/wolfskin-design/src/main/res/drawable/wolfskin_update.xml new file mode 100644 index 00000000..cfe21b10 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_update.xml @@ -0,0 +1,3 @@ + + + diff --git a/wolfskin-design/src/main/res/font/update_dejavu_sans.ttf b/wolfskin-design/src/main/res/font/update_dejavu_sans.ttf new file mode 100644 index 00000000..1728e9ec Binary files /dev/null and b/wolfskin-design/src/main/res/font/update_dejavu_sans.ttf differ diff --git a/wolfskin-design/src/main/res/font/update_dejavu_sans_bold.ttf b/wolfskin-design/src/main/res/font/update_dejavu_sans_bold.ttf new file mode 100644 index 00000000..0a7fb3f0 Binary files /dev/null and b/wolfskin-design/src/main/res/font/update_dejavu_sans_bold.ttf differ 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) - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellCaptureTest.kt index 6b508fd3..21a783d8 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellCaptureTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellCaptureTest.kt @@ -68,7 +68,7 @@ class ShellCaptureTest { @Test fun st014_discoverNav() = capture("st-014") { it.pickerVisible = false; it.navFocused = true; it.navIndex = 1 } @Test fun st091_settings() = capture("st-091") { it.pickerVisible = false; it.enterDestination("settings") } @Test fun st092_requestsCountFixture() = capture("st-092", requestCount = 5) { it.pickerVisible = false; it.enterDestination("settings") } - @Test fun st093_profileFocus() = capture("st-093") { it.pickerVisible = false; it.enterDestination("settings"); it.settingsRow = 4 } + @Test fun st093_profileFocus() = capture("st-093") { it.pickerVisible = false; it.enterDestination("settings"); it.settingsRow = 5 } } /** Tests and debug builds share the same shell-only projection of the locked fixture. */ diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellKeysTest.kt index 1bee07b1..8002c74d 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellKeysTest.kt @@ -46,6 +46,22 @@ class ShellKeysTest { assertFalse("Requests content, not the nav, must own focus", state.navFocused) } + @Test fun updatesUseTwoPressesFromRequestsWithoutClassicHandOff() { + val actions = mutableListOf() + val shell = ShellState(fixturePeople(), "justin", actions::add).apply { + pickerVisible = false + enterDestination("settings") + } + rule.setContent { WolfskinTheme { ShellSurface(shell) } } + rule.onNodeWithTag("wolfskin-shell").performKeyInput { + pressKey(Key.DirectionDown) + pressKey(Key.DirectionCenter) + } + assertEquals(1, shell.settingsRow) + assertNull(shell.handOff) + assertEquals(listOf(ShellAction.Destination("updates")), actions) + } + @Test fun settingsBackBackInTenPresses() { replay(settings + listOf(Key.Escape, Key.Escape), 10) assertEquals("home", state.route) @@ -54,10 +70,10 @@ class ShellKeysTest { assertEquals(state.tabs.size, state.navIndex) } - @Test fun playbackHandOffInElevenPresses() { - replay(settings + listOf(Key.DirectionDown, Key.DirectionDown, Key.DirectionCenter), 11) + @Test fun playbackHandOffInTwelvePresses() { + replay(settings + listOf(Key.DirectionDown, Key.DirectionDown, Key.DirectionDown, Key.DirectionCenter), 12) assertEquals("playback", state.handOff) - assertEquals(2, state.settingsRow) + assertEquals(3, state.settingsRow) } @Test fun rootBackAndNavDownPreserveViewAndWrap() { diff --git a/wolfskin-design/tokens.css b/wolfskin-design/tokens.css index efe1c530..b02ec55d 100644 --- a/wolfskin-design/tokens.css +++ b/wolfskin-design/tokens.css @@ -135,6 +135,9 @@ --layout-castCircle: 200.0px; --layout-personPortrait: 240.0px; --layout-row_chipsGrid: 136.0px; + --motion-updateScan: 1800ms; + --motion-updateSheen: 1600ms; + --motion-linear: cubic-bezier(0, 0, 1, 1); --motion-skeletonCycle: 1400ms; --motion-focusMove: 180ms; --motion-captionDelay: 60ms; @@ -519,4 +522,42 @@ --discover-stageGap: 10.0px; --discover-ambientInset: 80.0px; --discover-ambientBlur: 60.0px; + --update-avatarJustin: #7ab8ff; + --update-wordmarkWidth: 163.59375px; + --update-navHomeWidth: 149.328125px; + --update-navDiscoverWidth: 198.140625px; + --update-navMoviesWidth: 170.515625px; + --update-navShowsWidth: 161.15625px; + --update-navAnimeWidth: 158.46875px; + --update-notesListTop: 28.0px; + --update-notesListBottom: 28.0px; + --update-notesMarkerOffset: 11.0px; + --update-notesMarkerWidth: 29.0px; + --update-heading: 36.0px; + --update-headerTop: 150.0px; + --update-headerHeight: 84.0px; + --update-rowsTop: 278.0px; + --update-rowsViewport: 724.0px; + --update-contentTop: 298.0px; + --update-width: 1300.0px; + --update-padding: 40.0px; + --update-border: 2.0px; + --update-radius: 22.0px; + --update-titleBottom: 16.0px; + --update-paragraphBottom: 14.0px; + --update-actionsTop: 36.0px; + --update-actionGap: 24.0px; + --update-progressTop: 30.0px; + --update-progressBottom: 22.0px; + --update-progressHeight: 12.0px; + --update-progressRadius: 8.0px; + --update-autoTop: 862.0px; + --update-toggleWidth: 80.0px; + --update-toggleHeight: 42.0px; + --update-toggleThumb: 30.0px; + --update-toggleInset: 6.0px; + --update-notesHeight: 512.0px; + --update-notesGap: 18.0px; + --update-notesScrollStep: 80.0px; + --update-backTop: 184.0px; } diff --git a/wolfskin-design/tokens.json b/wolfskin-design/tokens.json index ea56e6d1..b04160f1 100644 --- a/wolfskin-design/tokens.json +++ b/wolfskin-design/tokens.json @@ -1400,6 +1400,9 @@ } }, "motion": { + "updateScan": { "$type": "duration", "$value": "1800ms", "source": "approved Updates progress busy scan" }, + "updateSheen": { "$type": "duration", "$value": "1600ms", "source": "approved Updates download highlight" }, + "linear": { "$type": "cubicBezier", "$value": [0, 0, 1, 1] }, "skeletonCycle": { "$type": "duration", "$value": "1400ms" }, "skeletonLowAlpha": { "$type": "number", "$value": 0.55 }, "focusMove": { "$type": "duration", "$value": "180ms" }, diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index 5306a8f6..d3684c03 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -1,8 +1,9 @@ b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json -c29dc64f3c3ca900c747b3db8b270e25908948bf5f76388d2c627a612fc7c99d generate_tokens.py +993d933950e17fb72d0902ffc440e1e4ed4dc088c96a8d12974431c77dc31634 generate_tokens.py 72b67ad9ba95e7bbc17cec29cc676b06b7e9d81c546022b5a100d6d4e2a27c64 home-tokens.json 192beb887fc34005db99c87ffbcbaf4a057ee2728475fc96224cf89445019b51 shell-tokens.json -8c47b082670b4805c9c961addee6ef8b567d874bf93d8f8dc4b160eef6d18b6a src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +42399062a6a22566e26adb71eaa4052b28be93d28d0eb46d9d4b87f3142a2944 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt 98fa6414cc6663624d09329615cdf0e6599baec570a8d878f5214c95cb33cb48 title-tokens.json -f188d525547d20d5751688573d8a81f8a2cb6c050dfb0141b8378842a7ff8a9c tokens.css -b1cf8f8caea9bfacb7994e0ba39e71ffab33452f6f5227a524ba71874b045b4d tokens.json +731ff25d6ec3ba12d0840523a1ebd65a81b3474cf74040f13bbd1f54985a3053 tokens.css +12d33b7b5740443e068cbb06528d88c42ad3bb1df0d4d45dd6ee6797d3dfc401 tokens.json +19728f99b90bc40c0ebec37c5bcc45b383b89b023211490bb3235c1690237ae5 update-tokens.json diff --git a/wolfskin-design/update-tokens.json b/wolfskin-design/update-tokens.json new file mode 100644 index 00000000..57d2454b --- /dev/null +++ b/wolfskin-design/update-tokens.json @@ -0,0 +1,167 @@ +{ + "update": { + "avatarJustin": {"$type": "color", "$value": "#7ab8ff"}, + "wordmarkWidth": {"$type": "dimension", "px": 163.59375, "dp": 81.796875}, + "navHomeWidth": {"$type": "dimension", "px": 149.328125, "dp": 74.6640625}, + "navDiscoverWidth": {"$type": "dimension", "px": 198.140625, "dp": 99.0703125}, + "navMoviesWidth": {"$type": "dimension", "px": 170.515625, "dp": 85.2578125}, + "navShowsWidth": {"$type": "dimension", "px": 161.15625, "dp": 80.578125}, + "navAnimeWidth": {"$type": "dimension", "px": 158.46875, "dp": 79.234375}, + "notesListTop": {"$type": "dimension", "px": 28, "dp": 14}, + "notesListBottom": {"$type": "dimension", "px": 28, "dp": 14}, + "notesMarkerOffset": {"$type": "dimension", "px": 11, "dp": 5.5}, + "notesMarkerWidth": {"$type": "dimension", "px": 29, "dp": 14.5}, + "heading": { + "$type": "typography", + "$value": {"fontWeight": 800}, + "px": 36, + "sp": 18, + "weight": 800, + "lineHeight": 1.12, + "letterSpacingEm": -0.02 + }, + "settingsVisibleRows": {"$type": "number", "$value": 6}, + "headerTop": { + "$type": "dimension", + "px": 150, + "dp": 75.0 + }, + "headerHeight": { + "$type": "dimension", + "px": 84, + "dp": 42.0 + }, + "rowsTop": { + "$type": "dimension", + "px": 278, + "dp": 139.0 + }, + "rowsViewport": { + "$type": "dimension", + "px": 724, + "dp": 362.0 + }, + "contentTop": { + "$type": "dimension", + "px": 298, + "dp": 149.0 + }, + "width": { + "$type": "dimension", + "px": 1300, + "dp": 650.0 + }, + "padding": { + "$type": "dimension", + "px": 40, + "dp": 20.0 + }, + "border": { + "$type": "dimension", + "px": 2, + "dp": 1.0 + }, + "radius": { + "$type": "dimension", + "px": 22, + "dp": 11.0 + }, + "titleBottom": { + "$type": "dimension", + "px": 16, + "dp": 8.0 + }, + "paragraphBottom": { + "$type": "dimension", + "px": 14, + "dp": 7.0 + }, + "actionsTop": { + "$type": "dimension", + "px": 36, + "dp": 18.0 + }, + "actionGap": { + "$type": "dimension", + "px": 24, + "dp": 12.0 + }, + "progressTop": { + "$type": "dimension", + "px": 30, + "dp": 15.0 + }, + "progressBottom": { + "$type": "dimension", + "px": 22, + "dp": 11.0 + }, + "progressHeight": { + "$type": "dimension", + "px": 12, + "dp": 6.0 + }, + "progressRadius": { + "$type": "dimension", + "px": 8, + "dp": 4.0 + }, + "autoTop": { + "$type": "dimension", + "px": 862, + "dp": 431.0 + }, + "toggleWidth": { + "$type": "dimension", + "px": 80, + "dp": 40.0 + }, + "toggleHeight": { + "$type": "dimension", + "px": 42, + "dp": 21.0 + }, + "toggleThumb": { + "$type": "dimension", + "px": 30, + "dp": 15.0 + }, + "toggleInset": { + "$type": "dimension", + "px": 6, + "dp": 3.0 + }, + "notesHeight": { + "$type": "dimension", + "px": 512, + "dp": 256.0 + }, + "notesGap": { + "$type": "dimension", + "px": 18, + "dp": 9.0 + }, + "notesScrollStep": { + "$type": "dimension", + "px": 80, + "dp": 40.0 + }, + "backTop": { + "$type": "dimension", + "px": 184, + "dp": 92.0 + }, + "scanFraction": { + "$type": "number", + "$value": 0.28 + }, + "scanEnd": { + "$type": "number", + "$value": 4.6 + }, + "sheenAlpha": { + "$type": "number", + "$value": 0.35 + } + } +}