diff --git a/Makefile b/Makefile index dc06bb0c..df6bd824 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 tv-release tv-publish tv-tooling-test +.PHONY: build-image apk apk-debug test release-tag build-image: docker build --platform linux/amd64 --tag "$(WOLFSKIN_BUILD_IMAGE)" docker @@ -19,13 +19,3 @@ 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 7566ce5e..b8f04e12 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,6 @@ 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 2da11b20..98f03a3a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -56,10 +56,6 @@ 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 @@ -232,13 +228,6 @@ 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 deleted file mode 100644 index c9ca35d0..00000000 --- a/app/household-version.gradle.kts +++ /dev/null @@ -1,18 +0,0 @@ -// 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 198782d5..20fc0df0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -36,13 +36,6 @@ - - - - - - - - - { val UpdateUrl = AppStringPreference( title = R.string.update_url, - defaultValue = UpdateChannel.defaultUrl(BuildConfig.APPLICATION_ID), + defaultValue = "https://git.justinvisser.org/api/v1/repos/justinzeus/wolfskin/releases/latest", 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 fa702609..706285f2 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,9 +36,6 @@ 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 @@ -46,7 +43,6 @@ 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 @@ -59,7 +55,6 @@ 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" @@ -128,10 +123,6 @@ 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 = @@ -190,12 +181,6 @@ 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() @@ -248,9 +233,6 @@ 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 cd9afc3e..0e388213 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,7 +47,6 @@ 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 @@ -90,7 +89,6 @@ 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 @@ -357,9 +355,7 @@ fun PreferencesContent( AppPreference.Update -> { ClickPreference( title = - if (BuildConfig.APPLICATION_ID == UpdateChannel.PACKAGE) { - stringResource(R.string.check_for_updates) - } else if (release != null && updateAvailable) { + if (release != null && updateAvailable) { stringResource(R.string.install_update) } else if (!preferences.autoCheckForUpdates && release == null) { stringResource(R.string.check_for_updates) @@ -368,9 +364,7 @@ fun PreferencesContent( }, onClick = { if (movementSounds) playOnClickSound(context) - if (BuildConfig.APPLICATION_ID == UpdateChannel.PACKAGE) { - viewModel.navigationManager.navigateTo(Destination.UpdateApp) - } else if (release != null && updateAvailable) { + 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 67366b9f..a3bd52e4 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,7 +44,6 @@ 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 @@ -74,9 +73,6 @@ 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 @@ -86,7 +82,6 @@ class UpdateViewModel private val userPreferencesService: UserPreferencesService, val updater: UpdateChecker, val navigationManager: NavigationManager, - val householdController: UpdateController, ) : ViewModel(), DownloadCallback { private val _state = MutableStateFlow(InstallUpdateState()) @@ -98,10 +93,6 @@ 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 @@ -175,10 +166,6 @@ 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 e7ec173a..2fde3eae 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,9 +52,6 @@ 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 925f5701..2e84e2ca 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,16 +18,6 @@ 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 93842728..fdd45e6a 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,14 +22,7 @@ class VersionCompareTests { @Test fun optimizedVersionRemainsCompatibleWithUpgradeAndUpdateChecks() { val optimizedVersion = Version.fromString(requireNotNull(System.getProperty("wolfskin.optimizedVersionName"))) - 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)) + Assert.assertEquals(Version.fromString(BuildConfig.VERSION_NAME), optimizedVersion) } @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 8d73f34f..3b4d3efa 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,16 +68,11 @@ import javax.inject.Singleton @Module @TestInstallIn( components = [SingletonComponent::class], - replaces = [DeviceModule::class, AppModule::class, org.justinvisser.wolfskin.tv.update.UpdateModule::class], + replaces = [DeviceModule::class, AppModule::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 ad3d4647..35e45892 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), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) 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 125a67bd..1af82b5c 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), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) 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 052aa05c..b748367e 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), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) + libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true)) 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), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) + libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true)) 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 c412265e..07cd1e37 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), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) + LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) 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 35c5cb57..95fcbb52 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), org.justinvisser.wolfskin.tv.update.FakeUpdateController()).also { models.add(it) } + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)).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 9a1ff11e..6a5936f8 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), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) 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), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) 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 01a2e425..1fb29d31 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), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) + mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) 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 8a01861d..dbcffdb4 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), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) 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), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) 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 deleted file mode 100644 index fc9c246b..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/update/HouseholdUpdateControllerTest.kt +++ /dev/null @@ -1,455 +0,0 @@ -// 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 deleted file mode 100644 index 3f6b81f3..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdateBehaviorTest.kt +++ /dev/null @@ -1,108 +0,0 @@ -// 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 deleted file mode 100644 index 947aace9..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdateManifestTest.kt +++ /dev/null @@ -1,49 +0,0 @@ -// 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 deleted file mode 100644 index 4f5f3159..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdateSessionStreamTest.kt +++ /dev/null @@ -1,34 +0,0 @@ -// 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 deleted file mode 100644 index e593675d..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdatesKeysTest.kt +++ /dev/null @@ -1,175 +0,0 @@ -// 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 caeb9543..aed8ec7b 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -80,7 +80,6 @@ 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 @@ -196,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" || action.route == "updates" -> navigation.navigateTo(target) + action.route == "settings" -> navigation.navigateTo(target) action.preservePushed && navigation.backStack.size > 1 -> navigation.backStack[0] = target else -> navigation.replace(target) } @@ -263,7 +262,6 @@ 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) { @@ -305,13 +303,7 @@ fun WolfskinApp( onBack = { if (navigationManager.backStack.last().fullScreen) navigationManager.goBack() else state.key(org.justinvisser.wolfskin.design.ShellKey.Back) }, entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()), entryProvider = { destination -> - 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(), - ) { + NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") { // Cached entries must not retain the launch shell after sign-in completes. val state = LocalShellState.current val discover = LocalDiscoverState.current @@ -337,15 +329,11 @@ 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 deleted file mode 100644 index aa30e022..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/AndroidUpdatePlatform.kt +++ /dev/null @@ -1,111 +0,0 @@ -// 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 deleted file mode 100644 index 0410e22e..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/AndroidUpdateService.kt +++ /dev/null @@ -1,58 +0,0 @@ -// 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 deleted file mode 100644 index 1c61e943..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/ClassicUpdatePage.kt +++ /dev/null @@ -1,82 +0,0 @@ -// 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 deleted file mode 100644 index ebac04ed..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/FakeUpdateController.kt +++ /dev/null @@ -1,36 +0,0 @@ -// 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 deleted file mode 100644 index 091be7a9..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/HouseholdUpdateController.kt +++ /dev/null @@ -1,223 +0,0 @@ -// 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 deleted file mode 100644 index 6b0c12ab..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateChannel.kt +++ /dev/null @@ -1,14 +0,0 @@ -// 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 deleted file mode 100644 index 75803a95..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateController.kt +++ /dev/null @@ -1,53 +0,0 @@ -// 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 deleted file mode 100644 index cd959f76..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateFocus.kt +++ /dev/null @@ -1,79 +0,0 @@ -// 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 deleted file mode 100644 index 497060af..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateInstallReceiver.kt +++ /dev/null @@ -1,61 +0,0 @@ -// 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 deleted file mode 100644 index 66a0240a..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateManifest.kt +++ /dev/null @@ -1,76 +0,0 @@ -// 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 deleted file mode 100644 index ec5de195..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateNavigation.kt +++ /dev/null @@ -1,13 +0,0 @@ -// 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 deleted file mode 100644 index a052fac5..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdatePresentation.kt +++ /dev/null @@ -1,92 +0,0 @@ -// 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 deleted file mode 100644 index fc23963c..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateProgress.kt +++ /dev/null @@ -1,58 +0,0 @@ -// 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 deleted file mode 100644 index b6c83467..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateTransport.kt +++ /dev/null @@ -1,149 +0,0 @@ -// 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 deleted file mode 100644 index 0db04aa7..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdatesScreen.kt +++ /dev/null @@ -1,202 +0,0 @@ -// 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 deleted file mode 100644 index a28e80c1..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/VerifiedUpdateStore.kt +++ /dev/null @@ -1,100 +0,0 @@ -// 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 a4a688da..ec71b8e7 100644 --- a/docker/README.md +++ b/docker/README.md @@ -11,11 +11,7 @@ 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 ``` -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 +APKs remain under `app/build/outputs/apk/default/{release,debug}/`. 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 @@ -31,159 +27,6 @@ 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 @@ -246,22 +89,20 @@ 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. 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. +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. -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: +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: ```sh bash docker/gradle.sh optimized --offline \ - -PtvVersion=1.1.1 -PtvVersionCode=1 \ -PWholphinExtensionsUsername=JustinZeus \ -PWholphinExtensionsPassword=unused \ :app:assembleDefaultOptimized @@ -274,8 +115,7 @@ 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 for diagnostic evidence; -do not install these direct-build APKs on the household box. +playback-complete comparison. Inspect native libraries before installing. Outputs are in `app/build/outputs/apk/default/optimized/`: universal, arm64-v8a, armeabi-v7a and x86_64. Keep @@ -345,15 +185,6 @@ 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 @@ -1118,12 +949,14 @@ 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` 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. +`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. ## Toolchain and disk use @@ -1170,25 +1003,21 @@ the checkout when building on another host, for example: WOLFSKIN_DEBUG_KEYSTORE="$HOME/.local/share/wolfskin-signing/debug.keystore" make apk-debug ``` -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 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. 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; the private key is nevertheless the permanent household signing -authority. It is distinct from the old production package's release signer. +are public; this key is for local debug/optimized builds, not release signing. ### Preserve household TV data -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: +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: ```text 182bac72e292b9ea4d3d4e6383f92eb2161fa28ba5afcff0337cb7d5a9d5341d @@ -1206,10 +1035,9 @@ 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. -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. +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. ## Release credentials diff --git a/docker/gradle.sh b/docker/gradle.sh index 7249cf82..461018bc 100755 --- a/docker/gradle.sh +++ b/docker/gradle.sh @@ -45,10 +45,6 @@ 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 @@ -70,15 +66,5 @@ else chmod 600 "$debug_keystore" arguments+=(--env ANDROID_USER_HOME=/tmp/.android --mount "type=bind,src=$debug_keystore,dst=/tmp/.android/debug.keystore,readonly") - 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 + docker run "${arguments[@]}" "$image" ./gradlew "${gradle_arguments[@]}" "$@" fi diff --git a/docker/test_tv_publish.py b/docker/test_tv_publish.py deleted file mode 100644 index 86ccce49..00000000 --- a/docker/test_tv_publish.py +++ /dev/null @@ -1,371 +0,0 @@ -"""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 deleted file mode 100644 index 4a5d679a..00000000 --- a/docker/test_tv_release.py +++ /dev/null @@ -1,159 +0,0 @@ -"""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 deleted file mode 100644 index 63071ca8..00000000 --- a/docker/tv-version-codes +++ /dev/null @@ -1,3 +0,0 @@ -# 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 deleted file mode 100755 index 7a1a131f..00000000 --- a/docker/tv_publish.py +++ /dev/null @@ -1,413 +0,0 @@ -#!/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 deleted file mode 100755 index 5ce4b890..00000000 --- a/docker/tv_release.py +++ /dev/null @@ -1,254 +0,0 @@ -#!/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 edc60ea2..d1275edb 100644 --- a/wolfskin-design/build.gradle.kts +++ b/wolfskin-design/build.gradle.kts @@ -19,23 +19,18 @@ android { } kotlin.compilerOptions.jvmTarget = JvmTarget.JVM_11 +val robolectricSdk by configurations.creating { + isCanBeResolved = true + isCanBeConsumed = false + isTransitive = false +} val cacheRobolectricSdk by tasks.registering(Copy::class) { - val versions = listOf( - "9-robolectric-4913185-2-i7", - "11-robolectric-6757853-i7", - "14-robolectric-10818077-i7", - ) - // One configuration would evict the older SDKs through version conflict resolution. - from(versions.map { version -> - project.configurations.detachedConfiguration( - project.dependencies.create("org.robolectric:android-all-instrumented:$version"), - ).apply { isTransitive = false } - }) + from(robolectricSdk) 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", "update-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", "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) @@ -55,6 +50,9 @@ tasks.withType().configureEach { } dependencies { + robolectricSdk("org.robolectric:android-all-instrumented:9-robolectric-4913185-2-i7") + robolectricSdk("org.robolectric:android-all-instrumented:11-robolectric-6757853-i7") + robolectricSdk("org.robolectric:android-all-instrumented:14-robolectric-10818077-i7") implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.ui) implementation(libs.androidx.tv.material) diff --git a/wolfskin-design/generate_tokens.py b/wolfskin-design/generate_tokens.py index d036e496..e18bc364 100755 --- a/wolfskin-design/generate_tokens.py +++ b/wolfskin-design/generate_tokens.py @@ -121,10 +121,9 @@ 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", "update-tokens.json", "generate_tokens.py"] + inputs = ["tokens.json", "shell-tokens.json", "title-tokens.json", "home-tokens.json", "discover-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 deleted file mode 100644 index 272739c0..00000000 --- a/wolfskin-design/src/main/assets/update-font-license.txt +++ /dev/null @@ -1,47 +0,0 @@ -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 1600e51c..4f1d281a 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 -fun CssColumn(modifier: Modifier = Modifier, gap: Dp = WolfskinTokens.Shell.zero, content: @Composable () -> Unit) { +internal 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 0039ab4a..5a9227d3 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,9 +11,6 @@ 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 6aec4bf2..266cbded 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,24 +21,19 @@ 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, prefix: String = "Wolf", textStyle: TextStyle = WolfskinTokens.Type.wordmark) { +fun Wordmark(modifier: Modifier = Modifier) { Row(modifier) { - Text(prefix, color = WolfskinTokens.Color.fg, style = textStyle) - Text("skin", color = WolfskinTokens.Color.accent, style = textStyle) + Text("Wolf", color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.wordmark) + Text("skin", color = WolfskinTokens.Color.accent, style = WolfskinTokens.Type.wordmark) } } /** 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, - backgroundColor: Color = personColor(person.key)) { - Box(modifier.size(diameter).background(backgroundColor, CircleShape), contentAlignment = Alignment.Center) { +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) { 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 17b0af02..40b8563c 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,16 +40,15 @@ 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, isUpdatesLayout: Boolean = false): List { - val updates = WolfskinTokens.Update - var left = L.safeSide + (if (isUpdatesLayout) updates.wordmarkWidth else S.wordmarkWidth) + L.wordmarkGap + L.navItemGap +fun navBoxes(tabs: List): List { + var left = L.safeSide + S.wordmarkWidth + L.wordmarkGap + L.navItemGap return tabs.map { tab -> val width = when (tab) { - "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 + "home" -> S.navHomeWidth + "discover" -> S.navDiscoverWidth + "movies" -> S.navMoviesWidth + "shows" -> S.navShowsWidth + 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) @@ -58,8 +57,7 @@ fun navBoxes(tabs: List, isUpdatesLayout: Boolean = false): List /** There is one blob, including when focus wraps around the nav avatar. */ @Composable fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) { - val hasSelectedTab = state.route != "settings" && state.route != "updates" - val boxes = remember(state.person.hasAnime, hasSelectedTab) { navBoxes(state.tabs, isUpdatesLayout = !hasSelectedTab) } + val boxes = remember(state.person.hasAnime) { navBoxes(state.tabs) } 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 @@ -81,15 +79,14 @@ 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) - if (state.navFocused || hasSelectedTab) drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2)) + drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2)) } - Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop), if (hasSelectedTab) "Wolf" else "wolf", - if (hasSelectedTab) WolfskinTokens.Type.wordmark else UpdateTypography.wordmark) + Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop)) 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 (hasSelectedTab && itemIndex == state.currentTab) C.fg else C.muted, + if (focused) C.onFocus else if (itemIndex == state.currentTab) C.fg else C.muted, animationSpec = labelFade, label = "nav-label-$tab", ) @@ -98,8 +95,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 = if (hasSelectedTab) WolfskinTokens.Type.nav else UpdateTypography.nav, color = labelColor) - if (hasSelectedTab && itemIndex == state.currentTab && !focused) { + Text(tab.replaceFirstChar { it.uppercase() }, style = WolfskinTokens.Type.nav, color = labelColor) + if (itemIndex == state.currentTab && !focused) { Box(Modifier.align(Alignment.BottomCenter).offset(y = -L.navUnderline).size(item.width - L.navItemPaddingX * 2, L.navUnderline).background(C.accent)) } } @@ -114,11 +111,10 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) } } val avatar = boxes.last() - Avatar(state.person, L.navAvatar, if (hasSelectedTab) WolfskinTokens.Type.avatarLetterNav else UpdateTypography.avatarLetterNav, + Avatar(state.person, L.navAvatar, WolfskinTokens.Type.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) }, - backgroundColor = if (hasSelectedTab) personColor(state.person.key) else resolveUpdateAvatarColor(state.person)) + .clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) }) } } 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 fe7adad1..08c79cd8 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,12 +9,13 @@ 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 @@ -35,53 +36,50 @@ 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) +data class SettingsGroup(val key: String, val title: String, val description: String, val wolfskin: Boolean = false) val SETTINGS_GROUPS = listOf( - 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"), + 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"), ) /** The count describes returned request history, not an inferred queue of active downloads. */ @Composable -fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null, installedVersion: String = "") { +fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null) { val list = state.view.listState - val reduced = reducedMotion() val density = LocalDensity.current LaunchedEffect(state.settingsRow, state.navFocused) { if (state.navFocused) return@LaunchedEffect - 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)) + 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)) } - 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) + 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) + } } - 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) - } + 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) } } } @@ -96,14 +94,15 @@ 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(if (group.key == "updates") "updates" else "settings-list", if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon)) + Glyph(group.key, 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(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) + 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) } - 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)) + 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)) } } @@ -123,6 +122,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 = T.caption.copy(fontWeight = T.label.fontWeight)) + Text(label, color = foreground, style = WolfskinTokens.Type.caption.copy(fontWeight = WolfskinTokens.Type.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 2992e93f..c770a3ab 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 @@ -260,13 +260,9 @@ class ShellState( } } else if (route == "settings") { val group = SETTINGS_GROUPS[settingsRow].key - when (group) { - "requests" -> openRequests() - "updates" -> onAction(ShellAction.Destination("updates")) - else -> { - handOff = group - onAction(ShellAction.HandOff(group)) - } + if (group == "requests") openRequests() else { + handOff = group + onAction(ShellAction.HandOff(group)) } } else contentFocus?.key(ShellKey.Ok) @@ -309,7 +305,6 @@ class ShellState( gearFocused = true pickerIndex = people.lastIndex } 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 cf1da9c4..603942b2 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,7 +56,6 @@ fun ShellSurface( modifier: Modifier = Modifier, handlesContentKeys: Boolean = true, settingsRequestCount: Int? = null, - settingsInstalledVersion: String = "", overlay: @Composable () -> Unit = {}, content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) }, ) { @@ -92,7 +91,7 @@ fun ShellSurface( if (state.pickerVisible) { WhosWatching(state) } else { - if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount, installedVersion = settingsInstalledVersion) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup()) + if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup()) NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.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 deleted file mode 100644 index e777372e..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/UpdateTypography.kt +++ /dev/null @@ -1,29 +0,0 @@ -// 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 e2103e63..6c014755 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,9 +221,6 @@ 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,48 +700,4 @@ 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 deleted file mode 100644 index bbf83f40..00000000 --- a/wolfskin-design/src/main/res/drawable/wolfskin_settings_chevron.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_settings_list.xml b/wolfskin-design/src/main/res/drawable/wolfskin_settings_list.xml deleted file mode 100644 index 362cc295..00000000 --- a/wolfskin-design/src/main/res/drawable/wolfskin_settings_list.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_update.xml b/wolfskin-design/src/main/res/drawable/wolfskin_update.xml deleted file mode 100644 index cfe21b10..00000000 --- a/wolfskin-design/src/main/res/drawable/wolfskin_update.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/wolfskin-design/src/main/res/font/update_dejavu_sans.ttf b/wolfskin-design/src/main/res/font/update_dejavu_sans.ttf deleted file mode 100644 index 1728e9ec..00000000 Binary files a/wolfskin-design/src/main/res/font/update_dejavu_sans.ttf and /dev/null 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 deleted file mode 100644 index 0a7fb3f0..00000000 Binary files a/wolfskin-design/src/main/res/font/update_dejavu_sans_bold.ttf and /dev/null 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 new file mode 100644 index 00000000..ec217257 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/OwnedSourceAuditTest.kt @@ -0,0 +1,66 @@ +// 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 21a783d8..6b508fd3 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 = 5 } + @Test fun st093_profileFocus() = capture("st-093") { it.pickerVisible = false; it.enterDestination("settings"); it.settingsRow = 4 } } /** 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 3cb4445c..77052d6f 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,22 +46,6 @@ 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.Back, Key.Back), 10) assertEquals("home", state.route) @@ -70,10 +54,10 @@ class ShellKeysTest { assertEquals(state.tabs.size, state.navIndex) } - @Test fun playbackHandOffInTwelvePresses() { - replay(settings + listOf(Key.DirectionDown, Key.DirectionDown, Key.DirectionDown, Key.DirectionCenter), 12) + @Test fun playbackHandOffInElevenPresses() { + replay(settings + listOf(Key.DirectionDown, Key.DirectionDown, Key.DirectionCenter), 11) assertEquals("playback", state.handOff) - assertEquals(3, state.settingsRow) + assertEquals(2, state.settingsRow) } @Test fun onlyLastPersonReachesGearAndUpReturnsThere() { diff --git a/wolfskin-design/tokens.css b/wolfskin-design/tokens.css index 1c06609a..cb81f2a5 100644 --- a/wolfskin-design/tokens.css +++ b/wolfskin-design/tokens.css @@ -135,9 +135,6 @@ --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,42 +516,4 @@ --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 b66734ad..d7db186e 100644 --- a/wolfskin-design/tokens.json +++ b/wolfskin-design/tokens.json @@ -1400,9 +1400,6 @@ } }, "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 2fd24fdd..a45b7f17 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -1,9 +1,8 @@ b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json -993d933950e17fb72d0902ffc440e1e4ed4dc088c96a8d12974431c77dc31634 generate_tokens.py +c29dc64f3c3ca900c747b3db8b270e25908948bf5f76388d2c627a612fc7c99d generate_tokens.py eef4e9f8c91306362540eda617c9efad4e19ed688f205f6f486625a5918d8105 home-tokens.json 192beb887fc34005db99c87ffbcbaf4a057ee2728475fc96224cf89445019b51 shell-tokens.json -8efafb988e507e367630212db4af4f40b529be55d0f90577a7be6dd98a86b4d4 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +3e2ad9a57c15c2ed4713abf383807bb567b27b38eddaaa36c00025e078864e12 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt 062597ea50dff9e6987485f9a83d412918dba3e10109524c26d3c2332a2f5b73 title-tokens.json -5f3b6d52c7ecfe49db59d2bd74acc1781ff7196db62bae3827d8f52b50d65aea tokens.css -3ed41f38aac01a608fb3e61c836180cf1ad76fe7cd1aac8b126f0a4adc0b2db1 tokens.json -19728f99b90bc40c0ebec37c5bcc45b383b89b023211490bb3235c1690237ae5 update-tokens.json +d7d6b17f8258083c879365f01e06729aa0fa12b69b7cd81e1bceabbbe6ab2a7d tokens.css +ba0da02b03047fbc2f29d538d980e87aa237f8ae36545aa4234600243fe14929 tokens.json diff --git a/wolfskin-design/update-tokens.json b/wolfskin-design/update-tokens.json deleted file mode 100644 index 57d2454b..00000000 --- a/wolfskin-design/update-tokens.json +++ /dev/null @@ -1,167 +0,0 @@ -{ - "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 - } - } -}