Compare commits

..

32 commits

Author SHA1 Message Date
root
5fb37886bb final merge dup-lazy-key
All checks were successful
gate/round2-candidate full make test 1016/1016 (7m43s, no retries), Opt build versionCode 68 signer ok
2026-11-07 20:55:19 +01:00
64432b0032 Compute lazy list keys from the list the lazy block renders
All checks were successful
review/code-claude Code APPROVE: no blocking finding
review/design-claude Design PASS: no visible change, no blocker
2026-11-07 20:54:53 +01:00
root
502d43f271 prep merge tv-skip-stock-setup 2026-11-07 20:50:32 +01:00
root
8b6edf5a96 prep merge tv-trailer-intent 2026-11-07 20:50:32 +01:00
root
abebd52850 prep merge discover 2026-11-07 20:50:32 +01:00
a5b5e92017 Route Wolfskin setup through the household profile picker
All checks were successful
review/code-claude Opus review PASS: no blockers, 10 non-blocking notes
review/safety-opus Opus review PASS: no blockers, 10 non-blocking notes
2026-11-07 20:46:46 +01:00
6f85b9c611 Make repeated ids safe as lazy list keys across the Wolfskin screens 2026-11-07 20:31:42 +01:00
8be769b67c Launch trailers with the vnd.youtube authority form SmartTube can parse
All checks were successful
review/code-claude Code APPROVE: no blocking finding
review/design-claude Design PASS: no visible change, no blocker
2026-11-07 19:48:04 +01:00
f375d88030 Reserve Home hero primary action space
All checks were successful
review/code-claude Code APPROVE: delta f375d880 fine
review/design-claude Design PASS: gear restored, hero buttons stationary
2026-11-07 19:46:17 +01:00
7c67596cb6 Restore approved picker Settings centring 2026-11-07 19:38:53 +01:00
a27e922675 Cover outgoing title and person Back ownership
Some checks failed
review/code-claude Code APPROVE: Home delta fine, no blocker
review/design-claude Design BLOCK: picker gear moved without approval (B1)
2026-11-07 19:19:45 +01:00
c5dbe91076 Start new Home rows leftmost and gate outgoing Back handlers 2026-11-07 18:52:30 +01:00
79f64f6e18 Restore sequential hero copy and first-appearance entry
All checks were successful
review/code-claude Code APPROVE: sequential copy hand-off, no blocker
review/design-claude Design PASS: copy hand-off matches approved frames/tokens
2026-11-07 18:44:00 +01:00
5811ab0555 Synchronize saved-picker test with Android main queue
All checks were successful
review/code-claude No blocking findings; 8 non-blocking notes (see PR comment)
review/design-claude Design review PASS: no blocking finding; 8 non-blocking notes
2026-11-07 18:18:50 +01:00
33cca64eba Merge remote-tracking branch 'origin/fm/tv-round2' into fm/tv-r2-home 2026-11-07 18:02:26 +01:00
9cfe9362ee Fix picker access and finish Wolfskin on root Back 2026-11-07 18:02:17 +01:00
7bfa431350 Retain destination focus in the initial title loading regression
Some checks failed
review/code-claude Code review: approve, no blocking finding (9 non-blocking notes)
review/design-claude Blocked: hero copy counter-fade overlaps text; approved sequential hand-off differs (B1)
2026-11-07 17:48:54 +01:00
aa31199b4f Merge title follow-up integration before final gates 2026-11-07 17:10:07 +01:00
2b142af8d1 Merge pull request 'Show episode ratings and smoothly collapse title readiness' (#57) from fm/tv-r2-title-2 into fm/tv-round2 2026-11-07 17:07:19 +01:00
f9bd630ea7 Keep title destination focus and preserve ready catalogue refreshes 2026-11-07 16:56:49 +01:00
e6ddf1d7e5 Show episode ratings and animate title readiness without empty space
All checks were successful
review/code-claude Reviewed e6ddf1d7: his board picks implemented, no blocking finding
review/design-claude Reviewed e6ddf1d7: his board picks implemented, no blocking finding
2026-11-07 16:41:07 +01:00
98dd54e03b Merge the TV round two integration branch 2026-11-07 16:22:09 +01:00
9988a193de Hold the full painted backdrop until incoming hero art is ready 2026-11-07 16:22:09 +01:00
174e1ae5c9 Merge current TV integration into title follow-up 2026-11-07 16:20:47 +01:00
febcded034 Merge round-two integration updates into Home work 2026-11-07 16:12:54 +01:00
ae613216d0 Merge main into the round-two integration branch 2026-11-07 16:01:56 +01:00
60b0c307aa Keep refresh capture artwork on the local fixture server 2026-11-07 15:59:00 +01:00
12e908389f Smooth Discover hero handoffs and scope lane focus to each visit 2026-11-07 15:56:19 +01:00
5046727b2a Merge pull request 'Refine TV title stacking, episode cards and action menus' (#56) from fm/tv-r2-title into fm/tv-round2 2026-11-07 15:53:33 +01:00
a159c835c5 Preserve episode height and fix title overlay and poster collisions
All checks were successful
review/code-claude Blockers fixed on a159c835, delta reviewed
review/design-claude Blockers fixed on a159c835, delta reviewed
2026-11-07 15:46:56 +01:00
12c201dc55 Fix Home navigation ownership and merge watch lanes 2026-11-07 15:23:45 +01:00
6d7513bd0e Refine title episode cards, section stacking and action menus
Some checks failed
review/code-claude Code review: pass, no blocking findings (9 non-blocking notes)
review/design-claude Blocked: card height 126 vs 140 dp, Menu to Media info flash, poster/cast overlap
2026-11-07 15:11:02 +01:00
161 changed files with 4414 additions and 4851 deletions

View file

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

View file

@ -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. 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 # Wholphin - an OSS Android TV client for Jellyfin

View file

@ -56,10 +56,6 @@ val gitDescribe =
.standardOutput.asText .standardOutput.asText
.getOrElse("v0.0.0") .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 { kotlin {
compilerOptions { compilerOptions {
languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3 languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3
@ -232,13 +228,6 @@ configure<ApplicationExtension> {
androidComponents { androidComponents {
onVariants(selector().all()) { variant -> 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 variant.outputs
.map { it as com.android.build.api.variant.impl.VariantOutputImpl } .map { it as com.android.build.api.variant.impl.VariantOutputImpl }
.forEach { output -> .forEach { output ->

View file

@ -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=<monotonic code>" }
require(tvVersionCode.get().toInt() in 1..2100000000) { "Invalid household versionCode" }
require(Regex("""\d+\.\d+\.\d+""").matches(tvVersion.get())) { "Household versionName must be plain major.minor.patch" }
}
if (gradle.startParameter.taskNames.any { it.contains("Optimized", ignoreCase = true) }) {
requireHouseholdVersion()
}
tasks.configureEach {
if (name.contains("DefaultOptimized", ignoreCase = true)) {
doFirst { requireHouseholdVersion() }
}
}

View file

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

View file

@ -33,6 +33,7 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface import androidx.tv.material3.Surface
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.RestoredSession import com.github.damontecres.wholphin.data.RestoredSession
import org.justinvisser.wolfskin.tv.recoverWolfskinSession
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.services.AppUpgradeHandler import com.github.damontecres.wholphin.services.AppUpgradeHandler
@ -479,11 +480,13 @@ class MainActivityViewModel
fun appResume() { fun appResume() {
viewModelScope.launchDefault { viewModelScope.launchDefault {
mutex.withLock { mutex.withLock {
val expected = serverRepository.current.value
try { try {
Timber.v("Updating userDto") Timber.v("Updating userDto")
serverRepository.updateUserDto() serverRepository.updateUserDto()
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.w(ex, "Error during appResume") Timber.w(ex, "Error during appResume")
recoverWolfskinSession(context, ex, expected, serverRepository, setupNavigationManager)
} }
} }
} }
@ -590,6 +593,7 @@ class MainActivityViewModel
} catch (exception: Exception) { } catch (exception: Exception) {
if (exception is kotlinx.coroutines.CancellationException) throw exception if (exception is kotlinx.coroutines.CancellationException) throw exception
Timber.w(exception, "Saved launch session revalidation failed") Timber.w(exception, "Saved launch session revalidation failed")
recoverWolfskinSession(context, exception, restoredSession.currentUser, serverRepository, setupNavigationManager)
} }
} }
} }

View file

@ -35,6 +35,7 @@ import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.ui.components.AppScreensaver import com.github.damontecres.wholphin.ui.components.AppScreensaver
import org.justinvisser.wolfskin.tv.WolfskinApp import org.justinvisser.wolfskin.tv.WolfskinApp
import org.justinvisser.wolfskin.tv.WolfskinSetupContent
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
import com.github.damontecres.wholphin.ui.util.InterfaceCustomization import com.github.damontecres.wholphin.ui.util.InterfaceCustomization
@ -48,6 +49,7 @@ fun MainContent(
backdropService: BackdropService, backdropService: BackdropService,
screensaverService: ScreensaverService, screensaverService: ScreensaverService,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
setupInterceptor: @Composable (SetupDestination, Modifier) -> Boolean = { key, contentModifier -> WolfskinSetupContent(key, contentModifier) },
) { ) {
val preferences by rememberUpdatedState(userPreferences) val preferences by rememberUpdatedState(userPreferences)
Surface( Surface(
@ -72,6 +74,7 @@ fun MainContent(
entryProvider = { key -> entryProvider = { key ->
key as SetupDestination key as SetupDestination
NavEntry(key) { NavEntry(key) {
if (setupInterceptor(key, Modifier.fillMaxSize())) return@NavEntry
when (key) { when (key) {
SetupDestination.Loading -> { SetupDestination.Loading -> {
Box( Box(

View file

@ -29,7 +29,7 @@ sealed interface HomeRowConfig {
fun updateViewOptions(viewOptions: HomeRowViewOptions): HomeRowConfig fun updateViewOptions(viewOptions: HomeRowViewOptions): HomeRowConfig
/** /**
* Continue watching media that the user has started but not finished * Legacy serialized resume row, migrated to ContinueWatchingCombined when settings are read.
*/ */
@Serializable @Serializable
@SerialName("ContinueWatching") @SerialName("ContinueWatching")
@ -40,7 +40,7 @@ sealed interface HomeRowConfig {
} }
/** /**
* Next up row for next episodes in a series the user has started * Legacy serialized next-up row, migrated to ContinueWatchingCombined when settings are read.
*/ */
@Serializable @Serializable
@SerialName("NextUp") @SerialName("NextUp")

View file

@ -0,0 +1,15 @@
package com.github.damontecres.wholphin.data.model
/** Saved split rows become one watch row at their first position, retaining its presentation choices. */
fun mergeHomeWatchRows(rows: List<HomeRowConfig>): List<HomeRowConfig> = buildList {
var watchRowSeen = false
for (row in rows) {
when (row) {
is HomeRowConfig.ContinueWatching, is HomeRowConfig.NextUp, is HomeRowConfig.ContinueWatchingCombined -> {
if (!watchRowSeen) add(HomeRowConfig.ContinueWatchingCombined(row.viewOptions))
watchRowSeen = true
}
else -> add(row)
}
}
}

View file

@ -16,7 +16,6 @@ import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
import com.github.damontecres.wholphin.ui.preferences.PreferenceValidation import com.github.damontecres.wholphin.ui.preferences.PreferenceValidation
import com.github.damontecres.wholphin.util.DebugLogTree import com.github.damontecres.wholphin.util.DebugLogTree
import org.justinvisser.wolfskin.tv.update.UpdateChannel
import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
@ -628,7 +627,7 @@ sealed interface AppPreference<Pref, T> {
val UpdateUrl = val UpdateUrl =
AppStringPreference<AppPreferences>( AppStringPreference<AppPreferences>(
title = R.string.update_url, 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 }, getter = { it.updateUrl },
setter = { prefs, value -> setter = { prefs, value ->
prefs.update { updateUrl = value } prefs.update { updateUrl = value }

View file

@ -8,7 +8,6 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
import com.github.damontecres.wholphin.ui.collectLatestIn import com.github.damontecres.wholphin.ui.collectLatestIn
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.google.common.cache.CacheBuilder import com.google.common.cache.CacheBuilder
import dagger.hilt.android.qualifiers.ActivityContext import dagger.hilt.android.qualifiers.ActivityContext
@ -21,13 +20,15 @@ import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.InvalidStatusException import org.jellyfin.sdk.api.client.exception.InvalidStatusException
import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest import org.jellyfin.sdk.api.client.extensions.itemsApi
import timber.log.Timber import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.UUID import java.util.UUID
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import timber.log.Timber
/** /**
* Caches when media was lasted played. This is mostly used by the combined Continue Watching & Next Up home row. * Caches when media was lasted played. This is mostly used by the combined Continue Watching & Next Up home row.
@ -46,50 +47,21 @@ class DatePlayedService
.expireAfterWrite(2, TimeUnit.HOURS) .expireAfterWrite(2, TimeUnit.HOURS)
.build<SeriesItemId, Deferred<LocalDateTime>>() .build<SeriesItemId, Deferred<LocalDateTime>>()
private fun getLastPlayed( private fun readLastPlayed(seriesId: UUID): Deferred<LocalDateTime> =
seriesId: UUID, scope.async(WholphinDispatchers.IO) {
item: BaseItem,
): Deferred<LocalDateTime> {
val request =
GetEpisodesRequest(
seriesId = seriesId,
adjacentTo = item.id,
limit = 1,
)
return scope.async(WholphinDispatchers.IO) {
val premiereDate = item.data.premiereDate
try { try {
val result = api.itemsApi.getItems(parentId = seriesId, recursive = true,
GetEpisodesRequestHandler includeItemTypes = listOf(BaseItemKind.EPISODE), isPlayed = true, enableUserData = true,
.execute( sortBy = listOf(ItemSortBy.DATE_PLAYED), sortOrder = listOf(SortOrder.DESCENDING), limit = 1)
api, .content.items.firstOrNull()?.userData?.lastPlayedDate ?: LocalDateTime.MIN
request,
).content.items
.firstOrNull()
?.userData
?.lastPlayedDate ?: LocalDateTime.MIN
if (premiereDate != null && result.isBefore(premiereDate)) {
premiereDate
} else {
result
}
} catch (ex: InvalidStatusException) { } catch (ex: InvalidStatusException) {
Timber.w( Timber.w(ex, "Error fetching series=%s", seriesId)
"Error fetching series=%s, item=%s: %s", LocalDateTime.MIN
seriesId,
item.id,
ex.localizedMessage,
)
LocalDateTime.MIN.coerceAtLeast(premiereDate ?: LocalDateTime.MIN)
} }
} }
}
/** /**
* Get the logical timestamp when the item was last played. * Get the series' latest actual watch activity, never an upcoming episode's premiere date.
*
* This is calculated using lastest of the actual last played timestamp of the item,
* the previous episode's last played timestamp, and the episode's premiere date
*/ */
suspend fun getLastPlayed(item: BaseItem): LocalDateTime? = suspend fun getLastPlayed(item: BaseItem): LocalDateTime? =
withContext(WholphinDispatchers.IO) { withContext(WholphinDispatchers.IO) {
@ -97,7 +69,7 @@ class DatePlayedService
return@withContext if (seriesId != null) { return@withContext if (seriesId != null) {
datePlayedCache datePlayedCache
.get(SeriesItemId(seriesId, item.id)) { .get(SeriesItemId(seriesId, item.id)) {
getLastPlayed(seriesId, item) readLastPlayed(seriesId)
}.await() }.await()
} else { } else {
null null

View file

@ -92,44 +92,8 @@ class HomeDataService
usePaging: Boolean = false, usePaging: Boolean = false,
): HomeRowLoadingState = ): HomeRowLoadingState =
when (row) { when (row) {
is HomeRowConfig.ContinueWatching -> { is HomeRowConfig.ContinueWatching,
val resume = is HomeRowConfig.NextUp,
latestNextUpService.getResume(
userDto.id,
limit,
true,
row.viewOptions.useSeries,
)
Success(
title = ResStringProvider(R.string.continue_watching),
items = resume,
viewOptions = row.viewOptions,
rowType = row,
showViewMore = resume.size >= limit,
)
}
is HomeRowConfig.NextUp -> {
val nextUp =
latestNextUpService.getNextUp(
userDto.id,
limit,
prefs.enableRewatchingNextUp,
false,
prefs.maxDaysNextUp,
row.viewOptions.useSeries,
)
Success(
title = ResStringProvider(R.string.next_up),
items = nextUp,
viewOptions = row.viewOptions,
rowType = row,
showViewMore = nextUp.size >= limit,
)
}
is HomeRowConfig.ContinueWatchingCombined -> { is HomeRowConfig.ContinueWatchingCombined -> {
val resume = val resume =
latestNextUpService.getResume( latestNextUpService.getResume(

View file

@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.data.model.HomeRowConfig
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
import com.github.damontecres.wholphin.data.model.mergeHomeWatchRows
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
import com.github.damontecres.wholphin.ui.AspectRatio import com.github.damontecres.wholphin.ui.AspectRatio
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
@ -159,7 +160,7 @@ class HomeSettingsService
null null
} }
}.orEmpty() }.orEmpty()
return HomePageSettings(rows, version) return HomePageSettings(mergeHomeWatchRows(rows), version)
} }
private suspend fun tryLoad(block: suspend () -> HomePageSettings?): HomePageSettings? = private suspend fun tryLoad(block: suspend () -> HomePageSettings?): HomePageSettings? =
@ -191,7 +192,7 @@ class HomeSettingsService
) )
return settings?.let { return settings?.let {
val resolvedRows = val resolvedRows =
settings.rows.mapIndexed { index, config -> mergeHomeWatchRows(settings.rows).mapIndexed { index, config ->
resolve(index, config) resolve(index, config)
} }
HomePageResolvedSettings(userId, resolvedSource, resolvedRows) HomePageResolvedSettings(userId, resolvedSource, resolvedRows)
@ -261,7 +262,7 @@ class HomeSettingsService
settings: HomePageSettings, settings: HomePageSettings,
) { ) {
val resolvedRows = val resolvedRows =
settings.rows.mapIndexed { index, config -> mergeHomeWatchRows(settings.rows).mapIndexed { index, config ->
resolve(index, config) resolve(index, config)
} }
currentSettings.update { it.copy(rows = resolvedRows) } currentSettings.update { it.copy(rows = resolvedRows) }
@ -313,7 +314,7 @@ class HomeSettingsService
listOf( listOf(
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = includedIds.size + 1, id = includedIds.size + 1,
title = ResStringProvider(R.string.combine_continue_next), title = ResStringProvider(R.string.continue_watching),
config = HomeRowConfig.ContinueWatchingCombined(), config = HomeRowConfig.ContinueWatchingCombined(),
), ),
) )
@ -434,7 +435,8 @@ class HomeSettingsService
null null
} }
}.flatten() }.flatten()
HomePageResolvedSettings(userId, HomePageSettingsSource.WEB_CONFIG, rowConfigs) val merged = mergeHomeWatchRows(rowConfigs.map { it.config }).mapIndexed { index, config -> resolve(index, config) }
HomePageResolvedSettings(userId, HomePageSettingsSource.WEB_CONFIG, merged)
} else { } else {
null null
} }
@ -457,19 +459,13 @@ class HomeSettingsService
) )
} }
is HomeRowConfig.ContinueWatching -> { is HomeRowConfig.ContinueWatching,
HomeRowConfigDisplay( is HomeRowConfig.NextUp,
id,
ResStringProvider(R.string.continue_watching),
config,
)
}
is HomeRowConfig.ContinueWatchingCombined -> { is HomeRowConfig.ContinueWatchingCombined -> {
HomeRowConfigDisplay( HomeRowConfigDisplay(
id, id,
ResStringProvider(R.string.combine_continue_next), ResStringProvider(R.string.continue_watching),
config, HomeRowConfig.ContinueWatchingCombined(config.viewOptions),
) )
} }
@ -495,14 +491,6 @@ class HomeSettingsService
HomeRowConfigDisplay(id, StringStringProvider(config.name), config) HomeRowConfigDisplay(id, StringStringProvider(config.name), config)
} }
is HomeRowConfig.NextUp -> {
HomeRowConfigDisplay(
id,
ResStringProvider(R.string.next_up),
config,
)
}
is HomeRowConfig.RecentlyAdded -> { is HomeRowConfig.RecentlyAdded -> {
val title = getItemName(R.string.recently_added_in, config.parentId) val title = getItemName(R.string.recently_added_in, config.parentId)
HomeRowConfigDisplay( HomeRowConfigDisplay(

View file

@ -158,7 +158,7 @@ class LatestNextUpService
val timestamps = mutableMapOf<UUID, LocalDateTime?>() val timestamps = mutableMapOf<UUID, LocalDateTime?>()
nextUp.map { it.id }.zip(nextUpLastPlayed).toMap(timestamps) nextUp.map { it.id }.zip(nextUpLastPlayed).toMap(timestamps)
resume.forEach { timestamps[it.id] = it.data.userData?.lastPlayedDate } resume.forEach { timestamps[it.id] = it.data.userData?.lastPlayedDate }
val result = (resume + nextUp).sortedByDescending { timestamps[it.id] } val result = (resume + nextUp).distinctBy { it.data.seriesId ?: it.id }.sortedByDescending { timestamps[it.id] }
val duration = (System.currentTimeMillis() - start).milliseconds val duration = (System.currentTimeMillis() - start).milliseconds
Timber.v("buildCombined took %s", duration) Timber.v("buildCombined took %s", duration)
return@withContext result return@withContext result

View file

@ -2,6 +2,8 @@ package com.github.damontecres.wholphin.services
import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavBackStack
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
import org.acra.ACRA import org.acra.ACRA
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -15,6 +17,11 @@ class NavigationManager
@Inject @Inject
constructor() { constructor() {
var backStack: MutableList<Destination> = NavBackStack(Destination.Home()) var backStack: MutableList<Destination> = NavBackStack(Destination.Home())
private val exits = Channel<Unit>(Channel.CONFLATED)
val exitRequests = exits.receiveAsFlow()
/** Finish the containing task; never expose an older setup/user entry. */
fun requestExit() { exits.trySend(Unit) }
/** /**
* Go to the specified [com.github.damontecres.wholphin.ui.nav.Destination] * Go to the specified [com.github.damontecres.wholphin.ui.nav.Destination]
@ -41,12 +48,14 @@ class NavigationManager
} }
/** /**
* Go to the previous page * Go to the previous page, or exit when there is no parent.
*/ */
fun goBack() { fun goBack() {
synchronized(this) { synchronized(this) {
if (backStack.size > 1) { if (backStack.size > 1) {
backStack.removeLastOrNull() backStack.removeLastOrNull()
} else {
requestExit()
} }
} }
log() log()

View file

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

View file

@ -1,7 +0,0 @@
package com.github.damontecres.wholphin.ui.main.settings
sealed interface HomeRowConfigAction {
data object Combine : HomeRowConfigAction
data object Split : HomeRowConfigAction
}

View file

@ -60,7 +60,6 @@ fun HomeRowSettings(
viewOptions: HomeRowViewOptions, viewOptions: HomeRowViewOptions,
onViewOptionsChange: (HomeRowViewOptions) -> Unit, onViewOptionsChange: (HomeRowViewOptions) -> Unit,
onConfigChange: (HomeRowConfig) -> Unit, onConfigChange: (HomeRowConfig) -> Unit,
onConfigAction: (HomeRowConfigAction) -> Unit,
onApplyApplyAll: () -> Unit, onApplyApplyAll: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
defaultViewOptions: HomeRowViewOptions = HomeRowViewOptions(), defaultViewOptions: HomeRowViewOptions = HomeRowViewOptions(),
@ -126,7 +125,6 @@ fun HomeRowSettings(
addAdditionalConfig( addAdditionalConfig(
config = config, config = config,
onConfigChange = onConfigChange, onConfigChange = onConfigChange,
onConfigAction = onConfigAction,
) )
} }
} }
@ -135,29 +133,8 @@ fun HomeRowSettings(
private fun LazyListScope.addAdditionalConfig( private fun LazyListScope.addAdditionalConfig(
config: HomeRowConfig, config: HomeRowConfig,
onConfigChange: (HomeRowConfig) -> Unit, onConfigChange: (HomeRowConfig) -> Unit,
onConfigAction: (HomeRowConfigAction) -> Unit,
) { ) {
when (config) { when (config) {
is HomeRowConfig.ContinueWatchingCombined -> {
item {
ClickPreference(
title = stringResource(R.string.split_into_separate_rows),
onClick = { onConfigAction.invoke(HomeRowConfigAction.Split) },
)
}
}
is HomeRowConfig.ContinueWatching,
is HomeRowConfig.NextUp,
-> {
item {
ClickPreference(
title = stringResource(R.string.combine_continue_next),
onClick = { onConfigAction.invoke(HomeRowConfigAction.Combine) },
)
}
}
is HomeRowConfig.ByParent -> { is HomeRowConfig.ByParent -> {
item { item {
val sortAndDirection = val sortAndDirection =

View file

@ -35,8 +35,6 @@ fun HomeSettingsAddRow(
) { ) {
itemsIndexed( itemsIndexed(
listOf( listOf(
MetaRowType.CONTINUE_WATCHING,
MetaRowType.NEXT_UP,
MetaRowType.COMBINED_CONTINUE_WATCHING, MetaRowType.COMBINED_CONTINUE_WATCHING,
), ),
) { index, type -> ) { index, type ->
@ -94,9 +92,7 @@ fun HomeSettingsAddRow(
enum class MetaRowType( enum class MetaRowType(
@param:StringRes val stringId: Int, @param:StringRes val stringId: Int,
) { ) {
CONTINUE_WATCHING(R.string.continue_watching), COMBINED_CONTINUE_WATCHING(R.string.continue_watching),
NEXT_UP(R.string.next_up),
COMBINED_CONTINUE_WATCHING(R.string.combine_continue_next),
FAVORITES(R.string.favorites), FAVORITES(R.string.favorites),
DISCOVER(R.string.discover), DISCOVER(R.string.discover),
COLLECTION(R.string.collection), COLLECTION(R.string.collection),

View file

@ -157,8 +157,6 @@ fun HomeSettingsPage(
onClick = { backStack.add(ChooseRowType(it)) }, onClick = { backStack.add(ChooseRowType(it)) },
onClickMeta = { onClickMeta = {
when (it) { when (it) {
MetaRowType.CONTINUE_WATCHING,
MetaRowType.NEXT_UP,
MetaRowType.COMBINED_CONTINUE_WATCHING, MetaRowType.COMBINED_CONTINUE_WATCHING,
-> { -> {
addRow { viewModel.addRow(it) } addRow { viewModel.addRow(it) }
@ -246,18 +244,6 @@ fun HomeSettingsPage(
onConfigChange = { onConfigChange = {
viewModel.onConfigChange(row, it) viewModel.onConfigChange(row, it)
}, },
onConfigAction = {
viewModel.onConfigAction(row, it)
when (it) {
HomeRowConfigAction.Combine,
HomeRowConfigAction.Split,
-> {
backStack.removeAt(
backStack.lastIndex,
)
}
}
},
) )
} }

View file

@ -22,6 +22,7 @@ import com.github.damontecres.wholphin.data.model.HomeRowConfig.TvChannels
import com.github.damontecres.wholphin.data.model.HomeRowConfig.TvPrograms import com.github.damontecres.wholphin.data.model.HomeRowConfig.TvPrograms
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
import com.github.damontecres.wholphin.data.model.mergeHomeWatchRows
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.HomeDataService import com.github.damontecres.wholphin.services.HomeDataService
@ -249,26 +250,10 @@ class HomeSettingsViewModel
val id = idCounter++ val id = idCounter++
val newRow = val newRow =
when (type) { when (type) {
MetaRowType.CONTINUE_WATCHING -> {
HomeRowConfigDisplay(
id = id,
title = ResStringProvider(R.string.continue_watching),
config = ContinueWatching(),
)
}
MetaRowType.NEXT_UP -> {
HomeRowConfigDisplay(
id = id,
title = ResStringProvider(R.string.next_up),
config = NextUp(),
)
}
MetaRowType.COMBINED_CONTINUE_WATCHING -> { MetaRowType.COMBINED_CONTINUE_WATCHING -> {
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = id, id = id,
title = ResStringProvider(R.string.combine_continue_next), title = ResStringProvider(R.string.continue_watching),
config = ContinueWatchingCombined(), config = ContinueWatchingCombined(),
) )
} }
@ -813,7 +798,7 @@ class HomeSettingsViewModel
if (result != null) { if (result != null) {
Timber.v("Got remote settings") Timber.v("Got remote settings")
val newRows = val newRows =
result.rows.mapIndexed { index, config -> mergeHomeWatchRows(result.rows).mapIndexed { index, config ->
homeSettingsService.resolve(index, config) homeSettingsService.resolve(index, config)
} }
idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0 idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0
@ -839,76 +824,6 @@ class HomeSettingsViewModel
} }
} }
fun onConfigAction(
row: HomeRowConfigDisplay,
action: HomeRowConfigAction,
) {
viewModelScope.launchDefault {
when (action) {
HomeRowConfigAction.Combine -> {
val index =
state.value.rows
.indexOf(row)
.coerceAtLeast(0)
val rowsToRemove =
state.value.rows
.filter { it.config is ContinueWatching || it.config is NextUp }
updateState {
it.copy(
loading = LoadingState.Loading,
rows =
it.rows.toMutableList().apply {
set(
index,
HomeRowConfigDisplay(
id = it.rows[index].id,
title = ResStringProvider(R.string.combine_continue_next),
config = ContinueWatchingCombined(row.config.viewOptions),
),
)
removeAll(rowsToRemove)
},
)
}
fetchRowData()
}
HomeRowConfigAction.Split -> {
val index =
state.value.rows
.indexOf(row)
.coerceAtLeast(0)
updateState {
it.copy(
loading = LoadingState.Loading,
rows =
it.rows.toMutableList().apply {
set(
index,
HomeRowConfigDisplay(
id = it.rows[index].id,
title = ResStringProvider(R.string.continue_watching),
config = ContinueWatching(row.config.viewOptions),
),
)
add(
index + 1,
HomeRowConfigDisplay(
id = idCounter++,
title = ResStringProvider(R.string.next_up),
config = NextUp(row.config.viewOptions),
),
)
},
)
}
fetchRowData()
}
}
}
}
fun onConfigChange( fun onConfigChange(
row: HomeRowConfigDisplay, row: HomeRowConfigDisplay,
config: HomeRowConfig, config: HomeRowConfig,

View file

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

View file

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

View file

@ -52,9 +52,6 @@ class SwitchSeerrViewModel
_state.update { it.copy(loading = LoadingState.Success) } _state.update { it.copy(loading = LoadingState.Success) }
} }
} }
} else {
// Nothing to check so set success
_state.update { it.copy(loading = LoadingState.Success) }
} }
} }

View file

@ -0,0 +1,29 @@
package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.data.model.HomeRowConfig
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.data.model.mergeHomeWatchRows
import org.junit.Assert.*
import org.junit.Test
/** Legacy saved watch lanes retain the first lane's order and display options after merging. */
class HomeWatchRowsTest {
@Test fun splitAndAlreadyCombinedRowsBecomeOneWithoutChangingOtherPresets() {
val options = HomeRowViewOptions(heightDp = 148)
val other = HomeRowConfig.GetItems(name = "Saved shelf", filter = com.github.damontecres.wholphin.data.model.HomeGetItemsFilter())
for (watchRows in listOf(
listOf(HomeRowConfig.ContinueWatching(options), other, HomeRowConfig.NextUp()),
listOf(HomeRowConfig.NextUp(options), other, HomeRowConfig.ContinueWatching()),
listOf(HomeRowConfig.ContinueWatchingCombined(options), other, HomeRowConfig.NextUp()),
)) {
val result = mergeHomeWatchRows(watchRows)
assertEquals(listOf(HomeRowConfig.ContinueWatchingCombined(options), other), result)
assertEquals(result, mergeHomeWatchRows(result))
}
}
@Test fun settingsWithoutWatchRowsStayWithoutWatchRows() {
val rows = listOf(HomeRowConfig.GetItems(name = "Saved shelf", filter = com.github.damontecres.wholphin.data.model.HomeGetItemsFilter()))
assertEquals(rows, mergeHomeWatchRows(rows))
}
}

View file

@ -18,16 +18,6 @@ import java.nio.file.Paths
import kotlin.io.path.readText import kotlin.io.path.readText
class TestUpdateChecker { 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 lateinit var releaseJson: JsonObject
val assetsJson: JsonArray by lazy { releaseJson["assets"]!!.jsonArray } val assetsJson: JsonArray by lazy { releaseJson["assets"]!!.jsonArray }

View file

@ -22,14 +22,7 @@ class VersionCompareTests {
@Test @Test
fun optimizedVersionRemainsCompatibleWithUpgradeAndUpdateChecks() { fun optimizedVersionRemainsCompatibleWithUpgradeAndUpdateChecks() {
val optimizedVersion = Version.fromString(requireNotNull(System.getProperty("wolfskin.optimizedVersionName"))) val optimizedVersion = Version.fromString(requireNotNull(System.getProperty("wolfskin.optimizedVersionName")))
Assert.assertTrue(optimizedVersion.isGreaterThan(Version.fromString("1.1.0-140-g5162c299"))) Assert.assertEquals(Version.fromString(BuildConfig.VERSION_NAME), optimizedVersion)
}
@Test
fun householdAvailabilityOrdersAndroidCodesNotDisplayVersions() {
Assert.assertTrue(org.justinvisser.wolfskin.tv.update.UpdateChannel.isNewer(100001, 65))
Assert.assertFalse(org.justinvisser.wolfskin.tv.update.UpdateChannel.isNewer(65, 100001))
Assert.assertFalse(org.justinvisser.wolfskin.tv.update.UpdateChannel.isNewer(100001, 100001))
} }
@Test @Test

View file

@ -68,16 +68,11 @@ import javax.inject.Singleton
@Module @Module
@TestInstallIn( @TestInstallIn(
components = [SingletonComponent::class], components = [SingletonComponent::class],
replaces = [DeviceModule::class, AppModule::class, org.justinvisser.wolfskin.tv.update.UpdateModule::class], replaces = [DeviceModule::class, AppModule::class],
) )
object TestModule { object TestModule {
val testDispatcher = StandardTestDispatcher() val testDispatcher = StandardTestDispatcher()
@Provides
@Singleton
fun updateController(): org.justinvisser.wolfskin.tv.update.UpdateController =
org.justinvisser.wolfskin.tv.update.FakeUpdateController()
@Provides @Provides
@Singleton @Singleton
fun deviceInfo( fun deviceInfo(

View file

@ -88,7 +88,7 @@ class AnimeAvailabilityTest {
} }
} }
val model = WolfskinShellViewModel(context, session, mockk(), mockk(), mockk(), mockk(relaxed = true), 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 { try {
requireNotNull(model.onForeground(server, user)).join() requireNotNull(model.onForeground(server, user)).join()
assertEquals("$key with $count Anime titles", count > 0, "anime" in session.state!!.tabs) assertEquals("$key with $count Anime titles", count > 0, "anime" in session.state!!.tabs)

View file

@ -69,7 +69,7 @@ class AnimeRootRemovalTest {
val navigation = NavigationManager() val navigation = NavigationManager()
val session = WolfskinSession() val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true), 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 { try {
model.load(server, user) model.load(server, user)
requireNotNull(model.onForeground(server, user)).join() requireNotNull(model.onForeground(server, user)).join()

View file

@ -49,7 +49,7 @@ class ConductarrAudienceRecoveryTest {
val client = session.identity.value!!.client val client = session.identity.value!!.client
val libraries = LibraryReadFacade(mockk()) val libraries = LibraryReadFacade(mockk())
val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true), 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 { try {
requireNotNull(model.onForeground(server, user)).join() requireNotNull(model.onForeground(server, user)).join()
assertNull(libraries.audiences) assertNull(libraries.audiences)
@ -82,7 +82,7 @@ class ConductarrAudienceRecoveryTest {
session.authenticate() session.authenticate()
val libraries = LibraryReadFacade(mockk()) val libraries = LibraryReadFacade(mockk())
val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true), 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 { try {
val identity = session.identity.value!! val identity = session.identity.value!!
val intent = org.justinvisser.wolfskin.design.DiscoverRequest( val intent = org.justinvisser.wolfskin.design.DiscoverRequest(

View file

@ -70,7 +70,7 @@ class ConductarrTitlePagesTest {
assertEquals(TitleConnection.SignedOut, revoked.connection) assertEquals(TitleConnection.SignedOut, revoked.connection)
assertTrue(revoked.item.ratings.isEmpty()) assertTrue(revoked.item.ratings.isEmpty())
assertTrue(revoked.playTarget!!.ratings.isEmpty()) assertTrue(revoked.playTarget!!.ratings.isEmpty())
assertFalse(TitleState(revoked, ShellPerson("shared", "Shared", "S", true)) {}.options.any { it.id == "add" }) assertFalse(TitleState(revoked, ShellPerson("shared", "Shared", "S", true)) {}.menuOptions.any { it.id == "add" })
} }
} }
} }

View file

@ -76,6 +76,9 @@ class ConductarrTitleSurfaceTest {
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() } val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "conductarr-title-real.png").absolutePath) rule.onRoot().captureRoboImage(File(directory, "conductarr-title-real.png").absolutePath)
rule.onRoot().performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionCenter) } rule.onRoot().performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionCenter) }
rule.mainClock.advanceTimeBy(300)
rule.onNodeWithText("Add for someone").assertIsDisplayed()
rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) }
rule.onNodeWithText("Request for Mindel").assertIsDisplayed() rule.onNodeWithText("Request for Mindel").assertIsDisplayed()
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown); pressKey(Key.DirectionCenter) } rule.onRoot().performKeyInput { pressKey(Key.DirectionDown); pressKey(Key.DirectionCenter) }
rule.waitUntil(30_000) { rule.waitUntil(30_000) {
@ -97,7 +100,8 @@ class ConductarrTitleSurfaceTest {
viewModel.title?.data?.connection == TitleConnection.SignedOut && viewModel.title?.data?.status == TitleReadStatus.Unavailable viewModel.title?.data?.connection == TitleConnection.SignedOut && viewModel.title?.data?.status == TitleReadStatus.Unavailable
} }
assertTrue(viewModel.title!!.data.item.ratings.isEmpty()) assertTrue(viewModel.title!!.data.item.ratings.isEmpty())
assertFalse(viewModel.title!!.options.any { it.id in listOf("request", "other", "add") }) assertFalse(viewModel.title!!.options.any { it.id == "request" })
assertFalse(viewModel.title!!.menuOptions.any { it.id == "add" })
} finally { viewModel.viewModelScope.cancel() } } finally { viewModel.viewModelScope.cancel() }
} }
} }

View file

@ -0,0 +1,115 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.graphics.Bitmap
import android.graphics.BitmapFactory
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.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onRoot
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import java.io.File
/** Refreshing a loaded Discover title must preserve its real header instead of rebuilding a skeleton. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class DiscoverRefreshMotionTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun loadedCatalogueRefreshKeepsReadyActionsOpaqueAndStationary() {
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
FakeConductarr().use { fake ->
fake.localArt = true
ConductarrSession(MemoryCacheStore()).use { session ->
session.select("server:shared", SHARED_TOKEN, fake.url)
runBlocking { session.authenticate() }
val facade = mockk<TitleReadFacade>()
coEvery { facade.findHeld(any(), any()) } returns null
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true))
val person = ShellPerson("shared", "Shared", "S", false)
val route = titleRoute("tv:44")
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) }
val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
val art = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.DKGRAY) }.asImageBitmap()
val refreshLookup = CompletableDeferred<java.util.UUID?>()
try {
rule.setContent { WolfskinTheme {
model.title?.let { state ->
val bitmaps = listOfNotNull(state.data.item.poster, state.data.item.backdrop).associateWith { art }
CompositionLocalProvider(LocalTitleBitmaps provides bitmaps) {
TitleSurface(state, shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight))
}
}
} }
rule.runOnIdle { model.load(route, person, preferences, fixtures = false) }
rule.waitUntil(30_000) {
Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
model.title?.data?.status == TitleReadStatus.Ready && model.title?.data?.cast?.isNotEmpty() == true && model.title?.data?.related?.isNotEmpty() == true
}
rule.mainClock.advanceTimeBy(640)
val directory = File(System.getProperty("wolfskin.captureDirectory"), "discover-refresh").apply { mkdirs() }
val bounds = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot
val pixels = IntArray(bounds.width.toInt() * bounds.height.toInt())
val baseline = brightness(File(directory, "loaded.png"), bounds, pixels)
coEvery { facade.findHeld(any(), any()) } coAnswers { refreshLookup.await() }
rule.runOnIdle {
model.load(route, person, preferences, fixtures = false, force = true)
assertEquals("An already-loaded catalogue title must never emit Loading on refresh", TitleReadStatus.Ready, model.title!!.data.status)
}
repeat(12) { index ->
rule.mainClock.advanceTimeBy(16)
assertEquals(TitleReadStatus.Ready, model.title!!.data.status)
assertFalse("A refresh cannot focus the root navigation", shell.navFocused)
assertEquals("Loaded actions cannot replay their rise", bounds, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot)
assertEquals("Loaded actions cannot replay their fade", baseline, brightness(File(directory, "refresh-${index.toString().padStart(2, '0')}.png"), bounds, pixels))
}
} finally {
model.viewModelScope.cancel()
refreshLookup.complete(null)
}
}
}
}
private fun brightness(file: File, bounds: Rect, pixels: IntArray): Long {
rule.onRoot().captureRoboImage(file.absolutePath)
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
return try {
val width = bounds.width.toInt()
val height = bounds.height.toInt()
bitmap.getPixels(pixels, 0, width, bounds.left.toInt(), bounds.top.toInt(), width, height)
pixels.sumOf { (android.graphics.Color.red(it) + android.graphics.Color.green(it) + android.graphics.Color.blue(it)).toLong() }
} finally { bitmap.recycle() }
}
}

View file

@ -91,7 +91,7 @@ class FirstSignInNavigationTest {
val navigation = NavigationManager() val navigation = NavigationManager()
val session = WolfskinSession() val session = WolfskinSession()
val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true), 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( 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"))) HomeTitle("episode", "Me and My Town", "episode", seriesTitle = "Desperate Housewives", season = 5, episode = 9)), heroKind = "resume")))
try { try {

View file

@ -17,7 +17,7 @@ import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
class HomeLibraryCacheTest { class HomeLibraryCacheTest {
@Test fun savedHomeReopensWithEpisodeMetadataAndFullShelfCount() { @Test fun savedHomeReopensWithEpisodeMetadataAndFullShelfCount() {
val context = ApplicationProvider.getApplicationContext<Application>() val context = ApplicationProvider.getApplicationContext<Application>()
val episode = HomeTitle("episode-id", "The next episode", "episode", year = 2025, runtimeSeconds = 1800, overview = "Overview", poster = "poster", backdrop = "backdrop", seriesTitle = "A series", season = 2, episode = 3, resumeSeconds = 125.5, addedAt = "2026-11-05", genres = listOf("Drama"), held = true, tmdbId = 42) val episode = HomeTitle("episode-id", "The next episode", "episode", year = 2025, runtimeSeconds = 1800, overview = "Overview", poster = "poster", backdrop = "backdrop", seriesTitle = "A series", season = 2, episode = 3, resumeSeconds = 125.5, addedAt = "2026-11-05", genres = listOf("Drama"), held = true, tmdbId = 42, seriesId = "series-id", lastPlayedAt = 123456L)
val film = HomeTitle("film-id", "A film", "movie", year = 2024, runtimeSeconds = 5400, played = true, held = true, tmdbId = 43) val film = HomeTitle("film-id", "A film", "movie", year = 2024, runtimeSeconds = 5400, played = true, held = true, tmdbId = 43)
val library = HomeLibrary(listOf(episode), listOf(episode.copy(id = "next", resumeSeconds = 0.0)), listOf(film), mapOf("movies" to listOf(film)), mapOf("movies" to 84)) val library = HomeLibrary(listOf(episode), listOf(episode.copy(id = "next", resumeSeconds = 0.0)), listOf(film), mapOf("movies" to listOf(film)), mapOf("movies" to 84))
HomeLibraryCache(context).write("server:person", library, 12345L) HomeLibraryCache(context).write("server:person", library, 12345L)
@ -26,4 +26,19 @@ class HomeLibraryCacheTest {
assertEquals(12345L, saved.second) assertEquals(12345L, saved.second)
assertNull(HomeLibraryCache(context).read("server:someone-else")) assertNull(HomeLibraryCache(context).read("server:someone-else"))
} }
@Test fun preMergeCacheWithoutWatchIdentityOrTimestampStillReopens() {
val context = ApplicationProvider.getApplicationContext<Application>()
val cache = HomeLibraryCache(context)
val title = HomeTitle("legacy", "An episode", "episode", seriesTitle = "Series", resumeSeconds = 24.0, held = true)
cache.write("legacy-scope", HomeLibrary(resume = listOf(title)), 12L)
val store = org.justinvisser.wolfskin.conductarr.FileCacheStore(java.io.File(context.filesDir, "wolfskin-home"))
val entry = store.read("legacy-scope", "home:jellyfin")!!
val body = org.json.JSONObject(entry.body)
body.getJSONArray("resume").getJSONObject(0).apply { remove("seriesId"); remove("lastPlayedAt") }
store.write("legacy-scope", "home:jellyfin", entry.copy(body = body.toString()))
val saved = cache.read("legacy-scope")!!
assertEquals(title, saved.first.resume.single())
assertEquals(12L, saved.second)
}
} }

View file

@ -85,7 +85,7 @@ class PersonPickerSessionTest {
val navigation = remember { NavigationManager() } val navigation = remember { NavigationManager() }
val model = remember { val model = remember {
WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, mockk(relaxed = true), 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() } } DisposableEffect(model) { onDispose { model.viewModelScope.cancel() } }
WolfskinApp(server, current.user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), WolfskinApp(server, current.user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),

View file

@ -87,7 +87,7 @@ class PersonSwitchThreadingTest {
coEvery { libraries.readDestinations(any()) } returns emptyMap() coEvery { libraries.readDestinations(any()) } returns emptyMap()
val session = WolfskinSession() val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true), 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<ConductarrClient>() val clients = mutableListOf<ConductarrClient>()
try { try {
model.load(server, justin) model.load(server, justin)
@ -163,7 +163,7 @@ class PersonSwitchThreadingTest {
coEvery { libraries.readDestinations(any()) } returns emptyMap() coEvery { libraries.readDestinations(any()) } returns emptyMap()
val session = WolfskinSession() val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true), 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 { try {
model.load(server, justin) model.load(server, justin)
requireNotNull(model.onForeground(server, justin)).join() requireNotNull(model.onForeground(server, justin)).join()

View file

@ -63,13 +63,18 @@ class SavedPeoplePickerTest {
val session = WolfskinSession() val session = WolfskinSession()
val navigation = NavigationManager() val navigation = NavigationManager()
val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true), 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 { try {
rule.setContent { rule.setContent {
WolfskinApp(server, users.first(), navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), WolfskinApp(server, users.first(), navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
viewModel = model, homeContent = { _, _, _, _ -> }) viewModel = model, homeContent = { _, _, _, _ -> })
} }
rule.waitUntil(5_000) { entered.get() && session.state?.people?.size == 3 } rule.waitUntil(5_000) {
// IO completion publishes Compose state on Android's queue, not the test frame clock.
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
entered.get() && session.state?.people?.size == 3
}
users.forEach { rule.onNodeWithText(it.name!!).assertIsDisplayed() } users.forEach { rule.onNodeWithText(it.name!!).assertIsDisplayed() }
rule.runOnIdle { rule.runOnIdle {
assertTrue(session.state!!.pickerVisible) assertTrue(session.state!!.pickerVisible)

View file

@ -66,7 +66,7 @@ class ShellStartupTimingTest {
coEvery { libraries.readDestinations(user.id) } returns emptyMap() coEvery { libraries.readDestinations(user.id) } returns emptyMap()
val session = WolfskinSession() val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true), 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) } val load = launch { model.load(server, user) }
try { try {
withTimeout(5_000) { savedPeopleStarted.await() } withTimeout(5_000) { savedPeopleStarted.await() }
@ -121,7 +121,7 @@ class ShellStartupTimingTest {
coEvery { libraries.readDestinations(any()) } returns emptyMap() coEvery { libraries.readDestinations(any()) } returns emptyMap()
val session = WolfskinSession() val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true), 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<Unit>() val pending = CompletableDeferred<Unit>()
val release = java.util.concurrent.CountDownLatch(1) val release = java.util.concurrent.CountDownLatch(1)
val firstAudience = java.util.concurrent.atomic.AtomicBoolean(true) val firstAudience = java.util.concurrent.atomic.AtomicBoolean(true)

View file

@ -6,6 +6,7 @@ import androidx.activity.ComponentActivity
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.performKeyInput
@ -69,9 +70,12 @@ class TitleAsyncFocusTest {
rule.setContent { WolfskinTheme { TitleDestination(route, shell, preferences, viewModel = viewModel) } } rule.setContent { WolfskinTheme { TitleDestination(route, shell, preferences, viewModel = viewModel) } }
rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading } rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading }
rule.waitForIdle() rule.waitForIdle()
assertTrue(shell.navFocused) assertFalse("Loading retains destination focus instead of focusing the root nav", shell.navFocused)
rule.onNodeWithTag("title-page").assertIsFocused()
press(Key.DirectionDown) press(Key.DirectionDown)
assertTrue("The initial skeleton cannot receive nav Down", shell.navFocused) assertFalse("Down cannot transfer focus to nav or an initial skeleton", shell.navFocused)
assertEquals("header", viewModel.title!!.row)
assertEquals(0, viewModel.title!!.column)
val source = TitleVersion("first", "Original", "1080p", "", "", "MKV", listOf(TitleTrack(3, "English", true)), emptyList()) val source = TitleVersion("first", "Original", "1080p", "", "", "MKV", listOf(TitleTrack(3, "English", true)), emptyList())
rule.runOnIdle { rule.runOnIdle {
@ -109,9 +113,12 @@ class TitleAsyncFocusTest {
} } } }
rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading } rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading }
rule.waitForIdle() rule.waitForIdle()
assertTrue(shell.navFocused) assertFalse(shell.navFocused)
rule.onNodeWithTag("title-page").assertIsFocused()
press(Key.DirectionDown) press(Key.DirectionDown)
assertTrue(shell.navFocused) assertFalse("Down cannot enter a pending person skeleton", shell.navFocused)
assertEquals("header", viewModel.title!!.row)
assertEquals(0, viewModel.title!!.column)
rule.runOnIdle { rule.runOnIdle {
reply.complete(org.justinvisser.wolfskin.tv.read.PersonRead( reply.complete(org.justinvisser.wolfskin.tv.read.PersonRead(
org.justinvisser.wolfskin.design.TitlePerson(id.toString(), "Actor"), org.justinvisser.wolfskin.design.TitlePerson(id.toString(), "Actor"),
@ -121,7 +128,7 @@ class TitleAsyncFocusTest {
rule.waitUntil(timeoutMillis = 5000) { viewModel.person != null || viewModel.title?.data?.status == TitleReadStatus.Unavailable } rule.waitUntil(timeoutMillis = 5000) { viewModel.person != null || viewModel.title?.data?.status == TitleReadStatus.Unavailable }
assertNotNull("A real person reply must populate the performer destination", viewModel.person) assertNotNull("A real person reply must populate the performer destination", viewModel.person)
rule.waitForIdle() rule.waitForIdle()
assertFalse("The loaded person must transfer nav ownership to its first credit", shell.navFocused) assertFalse("The loaded person retains destination focus on its first credit", shell.navFocused)
assertEquals(0, viewModel.person!!.index) assertEquals(0, viewModel.person!!.index)
press(Key.DirectionRight) press(Key.DirectionRight)
assertEquals(1, viewModel.person!!.index) assertEquals(1, viewModel.person!!.index)

View file

@ -87,7 +87,7 @@ class TitleCaptureTest {
data.cast.forEach { person -> person.portrait?.let { add(Triple(it, 200, 200)) } } data.cast.forEach { person -> person.portrait?.let { add(Triple(it, 200, 200)) } }
data.related.forEach { item -> item.poster?.let { add(Triple(it, 256, 384)) } } data.related.forEach { item -> item.poster?.let { add(Triple(it, 256, 384)) } }
data.episodes.forEach { item -> data.episodes.forEach { item ->
(if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, (WolfskinTokens.Card.tileW.value * 2).toInt(), (WolfskinTokens.Card.tileH.value * 2).toInt())) } (if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, (WolfskinTokens.Title.episodeCardWidth.value * 2).toInt(), (WolfskinTokens.Title.episodeCardHeight.value * 2).toInt())) }
} }
} }
return requests.distinctBy { it.first }.associate { (path, width, height) -> return requests.distinctBy { it.first }.associate { (path, width, height) ->
@ -170,7 +170,10 @@ class TitleCaptureTest {
@Test fun st044_versions() = capture(44) { state, shell -> open(state, shell, "version") } @Test fun st044_versions() = capture(44) { state, shell -> open(state, shell, "version") }
@Test fun st045_rememberedVersion() = capture(45) { state, _ -> state.versionId = "v2"; option(state, "version") } @Test fun st045_rememberedVersion() = capture(45) { state, _ -> state.versionId = "v2"; option(state, "version") }
@Test fun st046_tracks() = capture(46) { state, shell -> open(state, shell, "tracks") } @Test fun st046_tracks() = capture(46) { state, shell -> open(state, shell, "tracks") }
@Test fun st047_mediaInfo() = capture(47) { state, shell -> open(state, shell, "more") } @Test fun st047_mediaInfo() = capture(47) { state, shell ->
open(state, shell, "menu")
state.clickSheet(0)
}
@Test fun st048_trailerNoApp() = capture(48) { state, _ -> option(state, "trailer"); state.toast = "No YouTube app is available to play this trailer." } @Test fun st048_trailerNoApp() = capture(48) { state, _ -> option(state, "trailer"); state.toast = "No YouTube app is available to play this trailer." }
@Test fun st049_watched() = capture(49, transform = { it.copy(watchTarget = it.watchTarget.copy(watched = true)) }) { state, _ -> option(state, "watched") } @Test fun st049_watched() = capture(49, transform = { it.copy(watchTarget = it.watchTarget.copy(watched = true)) }) { state, _ -> option(state, "watched") }
@Test fun st050_seriesConfirm() = capture(50, "Andor") { state, shell -> open(state, shell, "watched") } @Test fun st050_seriesConfirm() = capture(50, "Andor") { state, shell -> open(state, shell, "watched") }
@ -216,4 +219,10 @@ class TitleCaptureTest {
@Test fun st108_resumingEpisodeRatings() = capture(108, "episode-sex-and-the-country-2001-s4e9", "mindel") @Test fun st108_resumingEpisodeRatings() = capture(108, "episode-sex-and-the-country-2001-s4e9", "mindel")
@Test fun st109_noRatings() = capture(109, "The Amazing Spider-Man") @Test fun st109_noRatings() = capture(109, "The Amazing Spider-Man")
@Test fun spacingCastFirstFocused() = capture(114) { state, _ -> state.focus("cast", 0) } @Test fun spacingCastFirstFocused() = capture(114) { state, _ -> state.focus("cast", 0) }
@Test fun round2Menu() = capture(120) { state, shell -> open(state, shell, "menu") }
@Test fun round2MussoliniResume() = capture(121, "episode-episode-1-2025-s1e1", "shared", transform = { it.copy(episodes = it.episodes.distinctBy { episode -> episode.id }) })
@Test fun round2Episodes() = capture(122, "episode-episode-1-2025-s1e1", "shared", transform = { it.copy(episodes = it.episodes.distinctBy { episode -> episode.id }) }) { state, _ -> state.focus("episodes", 0) }
@Test fun round2NotHeldSeries() = capture(123, "90 Day Fiancé: Before the 90 Days")
@Test fun round2HeldPosterFallback() = capture(124, "The Girl Who Leapt Through Time", transform = { it.copy(item = it.item.copy(backdrop = null, isPosterBackdrop = true, overview = "Short overview.")) })
@Test fun round2NotHeldShortOverview() = capture(125, "The Amazing Spider-Man", transform = { it.copy(item = it.item.copy(backdrop = null, isPosterBackdrop = true, overview = "Short overview.")) })
} }

View file

@ -0,0 +1,107 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.os.Looper
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.nav.Destination
import io.mockk.coEvery
import io.mockk.mockk
import java.util.UUID
import kotlinx.coroutines.cancel
import org.junit.After
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.PersonRead
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows
import org.robolectric.annotation.Config
/** A loaded outgoing destination remains composed after pop but cannot consume the incoming Home Back. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class TitleEntryBackTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private lateinit var viewModel: WolfskinTitleViewModel
private lateinit var conductarr: ConductarrSession
@After fun dispose() {
if (::viewModel.isInitialized) viewModel.viewModelScope.cancel()
if (::conductarr.isInitialized) conductarr.close()
}
@Test fun outgoingLoadedTitleCannotConsumeTheNextHomeBack() = verifyOutgoingBack(personPage = false)
@Test fun outgoingLoadedPersonCannotConsumeTheNextHomeBack() = verifyOutgoingBack(personPage = true)
private fun verifyOutgoingBack(personPage: Boolean) {
val activity = rule.activity
val navigation = NavigationManager()
val viewer = ShellPerson("justin", "Justin", "J", false)
val shell = ShellState(listOf(viewer), viewer.key) {
if (it == ShellAction.Exit) navigation.requestExit()
}.apply { pickerVisible = false; pickerLaunch = false; navFocused = false }
val id = UUID.fromString("00000000-0000-0000-0000-000000000031")
val route = if (personPage) personRoute(id.toString()) else titleRoute(id.toString())
val destination = Destination.Wolfskin(route)
val facade = mockk<TitleReadFacade>()
if (personPage) {
coEvery { facade.readPersonSaved(id) } returns null
coEvery { facade.readPerson(id, 0) } returns PersonRead(TitlePerson(id.toString(), "Outgoing actor"),
listOf(TitleItem("credit", "Credit", "movie")))
} else {
coEvery { facade.readSaved(id, any()) } returns null
coEvery { facade.read(id, any(), any()) } returns TitlePageData(TitleItem(id.toString(), "Outgoing film", "movie", held = true))
}
conductarr = ConductarrSession(MemoryCacheStore())
viewModel = WolfskinTitleViewModel(activity, facade, mockk<TitleFixtureProvider>(), navigation, conductarr,
mockk<ServerRepository>(relaxed = true))
shell.enterDestination(route)
navigation.navigateTo(destination)
val home = HomeSnapshot(listOf(HomeRow("hero", HomeRowKind.Hero, cards = listOf(HomeTitle("home-film", "Home film", "movie")))))
val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
rule.setContent {
WolfskinTheme {
rememberWolfskinBackHandler(shell, navigation)
val current = navigation.backStack.last()
LaunchedEffect(current) { if (current is Destination.Home) shell.enterDestination("home") }
Box {
ScreenEntry(shell, "home", current is Destination.Home) { HomeScreen(shell, home) {} }
// Retain the real outgoing destination just as NavDisplay does during its exit animation.
ScreenEntry(shell, route, current == destination) {
TitleDestination(route, shell, preferences, viewModel = viewModel, fixtures = false)
}
}
}
}
rule.waitUntil(5_000) {
Snapshot.sendApplyNotifications()
Shadows.shadowOf(Looper.getMainLooper()).idle()
(if (personPage) viewModel.person != null else viewModel.title?.data?.status == TitleReadStatus.Ready) && !shell.navFocused
}
rule.runOnIdle { activity.onBackPressedDispatcher.onBackPressed() }
rule.waitForIdle()
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
assertFalse(shell.navFocused)
rule.runOnIdle { activity.onBackPressedDispatcher.onBackPressed() }
rule.mainClock.advanceTimeBy(16)
assertTrue("The incoming Home must receive Back while the outgoing entry is retained", shell.navFocused)
assertFalse("A second Back must not finish the task through the outgoing entry", activity.isFinishing)
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
rule.runOnIdle { activity.onBackPressedDispatcher.onBackPressed() }
rule.mainClock.advanceTimeBy(16)
assertTrue("Only the following Back from Home nav should exit", activity.isFinishing)
}
}

View file

@ -12,11 +12,9 @@ import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performMouseInput import androidx.compose.ui.test.performMouseInput
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasText import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.text.TextLayoutResult
import org.justinvisser.wolfskin.design.WolfskinTokens import org.justinvisser.wolfskin.design.WolfskinTokens
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
@ -36,7 +34,6 @@ import org.justinvisser.wolfskin.design.TitleSurface
import org.justinvisser.wolfskin.design.WolfskinTheme import org.justinvisser.wolfskin.design.WolfskinTheme
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Approved title-entry suffixes replay Android keys through the rendered title and shared shell. */ /** Approved title-entry suffixes replay Android keys through the rendered title and shared shell. */
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@ -284,24 +281,19 @@ class TitleKeysTest {
press(Key.DirectionDown) press(Key.DirectionDown)
assertEquals(expected, state.row) assertEquals(expected, state.row)
} }
@Test fun seriesOptionsKeepTheCompleteHouseholdActionLabel() {
render("Andor")
val layouts = mutableListOf<TextLayoutResult>()
rule.onNodeWithText("Add for someone", useUnmergedTree = true)
.performSemanticsAction(SemanticsActions.GetTextLayoutResult) { action -> action(layouts) }
val layout = layouts.single()
assertFalse("The final pill must not shrink to its icon or a clipped label", layout.didOverflowWidth)
assertEquals(1, layout.lineCount)
}
@Test fun anInitialLoadNeverExposesStaleCastBelowItsSkeleton() { @Test fun anInitialLoadNeverExposesStaleCastBelowItsSkeleton() {
render(transform = { it.copy(status = TitleReadStatus.Loading) }) render(transform = { it.copy(status = TitleReadStatus.Loading) })
rule.onNodeWithText("Cast").assertDoesNotExist() rule.onNodeWithText("Cast").assertDoesNotExist()
press(Key.DirectionDown) press(Key.DirectionDown)
assertTrue(shell.navFocused) assertFalse(shell.navFocused)
rule.onNodeWithTag("title-page").assertIsFocused()
assertEquals("header", state.row) assertEquals("header", state.row)
assertEquals(0, state.column)
assertFalse(shell.pickerVisible)
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() } rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
assertEquals(listOf(TitleAction.Pop), effects) assertEquals(listOf(TitleAction.Pop), effects)
assertFalse(shell.pickerVisible)
} }
@ -315,26 +307,6 @@ class TitleKeysTest {
assertEquals(WolfskinTokens.Title.factsGap.value * 2, genre.left - year.right, 1f) assertEquals(WolfskinTokens.Title.factsGap.value * 2, genre.left - year.right, 1f)
} }
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Test fun mediaInfoRowsPreserveTheirDeclaredLineBoxes() {
render()
rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "more" }) }
press(Key.DirectionCenter)
val container = rule.onNodeWithText("Container", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
val size = rule.onNodeWithText("Size", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
val expectedPitch = (WolfskinTokens.Title.infoKey.lineHeight.value + WolfskinTokens.Title.infoRowGap.value) * 2
assertEquals("Stacked facts retain the CSS line-box rhythm", expectedPitch, size.top - container.top, 1f)
val lastSubtitle = rule.onNodeWithText("Dutch", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
assertEquals("Fourteen stacked lines do not accumulate native pixel rounding", 526.2f, lastSubtitle.top - container.top, 1f)
}
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Test fun filmHeaderKeepsItsFixedCssStack() {
render()
assertEquals(358f, rule.onNodeWithTag("title-facts").fetchSemanticsNode().boundsInRoot.top, 1f)
assertEquals(410f, rule.onNodeWithTag("title-overview").fetchSemanticsNode().boundsInRoot.top, 1f)
assertEquals(553f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f)
}
@Test fun everyLongTrackChoiceAndDoneRemainInsideTheVisibleSheet() { @Test fun everyLongTrackChoiceAndDoneRemainInsideTheVisibleSheet() {

View file

@ -0,0 +1,288 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.graphics.BitmapFactory
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.unit.dp
import com.github.takahirom.roborazzi.captureRoboImage
import org.json.JSONObject
import org.junit.Assert.*
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
import java.io.File
/** Native title interactions keep secondary actions, focus and section geometry intact. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class TitleRound2Test {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val effects = mutableListOf<TitleAction>()
private lateinit var state: TitleState
private lateinit var shell: ShellState
private fun fixture() = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) })
private fun render(title: String = "Locke", person: String = "justin", transform: (TitlePageData) -> TitlePageData = { it }) {
val fixture = fixture()
shell = ShellState(fixture.people, person).apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/test") }
state = TitleState(transform(fixture.title(title, person)), shell.person, effects::add)
rule.setContent { WolfskinTheme {
BackHandler { state.back(shell) }
TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp))
} }
}
private fun press(key: Key) = rule.onRoot().performKeyInput { pressKey(key) }
private fun menu() {
rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "menu" }) }
press(Key.DirectionCenter)
rule.mainClock.advanceTimeBy(300)
rule.waitForIdle()
rule.onNodeWithTag("title-menu", useUnmergedTree = true).assertIsDisplayed()
}
@Test fun menuBackRestoresItsButtonAndKeepsMediaInfoAndRequestReachable() {
render()
menu()
val opener = state.column
press(Key.Back)
assertNull(state.sheet)
assertEquals(opener, state.column)
assertFalse(shell.navFocused)
press(Key.DirectionCenter)
assertEquals(TitleSheet.Menu, state.sheet)
press(Key.DirectionCenter)
assertEquals(TitleSheet.MediaInfo, state.sheet)
rule.onNodeWithText("Container", useUnmergedTree = true).assertIsDisplayed()
press(Key.Back)
assertEquals(TitleSheet.Menu, state.sheet)
press(Key.DirectionDown)
press(Key.DirectionCenter)
assertNull(state.sheet)
assertEquals(TitleAction.Request(state.data.item, true), effects.single())
assertEquals(opener, state.column)
}
@Test fun notHeldMovieKeepsRequestAndHideOutsideTheMenu() {
render("The Amazing Spider-Man")
rule.onNodeWithTag("title-option-request").assertIsDisplayed()
rule.onNodeWithTag("title-option-hide").assertIsDisplayed()
menu()
rule.onNodeWithText("Add for someone").assertIsDisplayed()
press(Key.DirectionCenter)
assertEquals(TitleAction.Request(state.data.item, true), effects.single())
}
@Test fun movieCastStacksAfterActionsInsteadOfAReservedHeaderHeight() {
render("The Amazing Spider-Man")
val actions = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot
val cast = rule.onNodeWithTag("title-cast").fetchSemanticsNode().boundsInRoot
val gap = cast.top - actions.bottom
assertTrue("Cast follows the actions without the old fixed header reservation", gap > 0 && cast.top < WolfskinTokens.Layout.row_dhead.value * 2)
rule.runOnIdle { state.update(state.data.copy(requestedStage = "Searching", requestActor = "Justin")) }
val requestedActions = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot
val requestedCast = rule.onNodeWithTag("title-cast").fetchSemanticsNode().boundsInRoot
assertTrue(requestedActions.bottom < requestedCast.top)
assertTrue("A readiness line cannot move Cast backwards or recreate the old reservation", requestedCast.top >= cast.top && requestedCast.top < WolfskinTokens.Layout.row_dhead.value * 2)
}
@Test fun mussoliniResumeHasOnlyFactsRatingsAndNoRedundantSeasonsAction() {
render("episode-episode-1-2025-s1e1", "shared") { data -> data.copy(episodes = data.episodes.distinctBy { it.id }) }
assertTrue(state.data.playTarget!!.resumeSeconds > 0)
rule.onNodeWithTag("title-progress").assertIsDisplayed()
rule.onAllNodesWithContentDescription("rtCritics", useUnmergedTree = true).assertCountEquals(1)
rule.onNodeWithText("Ep", useUnmergedTree = true).assertIsDisplayed()
rule.onAllNodesWithText("7.4", useUnmergedTree = true).assertCountEquals(1)
rule.onNode(hasText("7.4") and hasAnyAncestor(hasTestTag("title-facts")), useUnmergedTree = true).assertIsDisplayed()
rule.onNodeWithTag("title-option-seasons").assertDoesNotExist()
press(Key.DirectionDown)
assertEquals("seasons", state.row)
rule.onNode(hasText("Season 1") and hasClickAction()).assertIsDisplayed()
}
@Test fun seriesUsesDownToReachSeasonsWithoutARedundantShortcut() {
render("Andor")
rule.onNodeWithTag("title-option-seasons").assertDoesNotExist()
menu()
rule.onNodeWithText("Add for someone").assertIsDisplayed()
press(Key.Back)
press(Key.DirectionDown)
assertEquals("seasons", state.row)
rule.onNode(hasText("Season 1") and hasClickAction()).assertIsDisplayed()
}
@Test fun episodeFactsUseTheEpisodeSetRatherThanTheSeriesSet() {
render("episode-episode-1-2025-s1e1", "shared") { data ->
data.copy(item = data.item.copy(ratings = listOf(LibraryRating("rtCritics", 20.0, "Rotten"))),
watchTarget = data.watchTarget.copy(ratings = listOf(LibraryRating("rtCritics", 84.0, "Fresh"), LibraryRating("tmdb", 8.7, episode = true))),
episodes = data.episodes.distinctBy { it.id })
}
rule.onAllNodesWithText("84%", useUnmergedTree = true).assertCountEquals(1)
rule.onAllNodesWithText("8.7", useUnmergedTree = true).assertCountEquals(1)
rule.onNodeWithText("20%", useUnmergedTree = true).assertDoesNotExist()
rule.onNode(hasText("8.7") and hasAnyAncestor(hasTestTag("title-facts")), useUnmergedTree = true).assertIsDisplayed()
}
@Test fun episodeSkeletonAndReadySlotsMatchWhileMetadataFitsOneLine() {
render("Andor") { it.copy(episodesStatus = TitleReadStatus.Loading) }
rule.runOnIdle { shell.view.listState.requestScrollToItem(2) }
rule.waitForIdle()
val skeleton = rule.onNodeWithTag("title-episode-slot-0").fetchSemanticsNode().boundsInRoot
rule.runOnIdle { state.update(state.data.copy(episodesStatus = TitleReadStatus.Ready)) }
val ready = rule.onNodeWithTag("title-episode-slot-0").fetchSemanticsNode().boundsInRoot
assertEquals(skeleton, ready)
assertEquals(448f, ready.width, 0.1f)
assertEquals("Wider cards retain the approved taller episode art", 280f, ready.height, 0.1f)
rule.runOnIdle { state.focus("episodes", 0) }
val episode = state.data.episodes.first()
rule.onNodeWithText(episode.episodeLabel + " · " + episode.runtime).assertIsDisplayed()
rule.onNode(hasText(episode.seriesName!!) and hasAnyAncestor(hasTestTag("title-episode-slot-0")), useUnmergedTree = true).assertDoesNotExist()
}
@Test fun revokedRequestsDisappearFromMenuWithoutRemovingMediaInfo() {
render()
menu()
rule.runOnIdle { state.update(state.data.copy(requestsAllowed = false)) }
rule.onNodeWithText("Add for someone").assertDoesNotExist()
press(Key.DirectionCenter)
assertEquals(TitleSheet.MediaInfo, state.sheet)
rule.onNodeWithText("Container", useUnmergedTree = true).assertIsDisplayed()
}
@Test fun shortOverviewMoviesKeepCastBelowThePosterForHeldAndNotAddedTitles() {
render("The Amazing Spider-Man") { it.copy(item = it.item.copy(overview = "Short overview.", backdrop = null, isPosterBackdrop = true)) }
for (held in listOf(false, true)) {
rule.runOnIdle { state.update(state.data.copy(item = state.data.item.copy(held = held))) }
val poster = rule.onNodeWithTag("title-poster-backdrop").fetchSemanticsNode().boundsInRoot
val firstActor = rule.onNodeWithTag("title-cast-${state.data.cast.first().id}").fetchSemanticsNode().boundsInRoot
assertTrue("Cast clears the poster on held=$held: $firstActor vs $poster", firstActor.top >= poster.bottom + 20f - 0.5f)
}
}
@Test fun revokingTheOnlyMenuActionClosesAnOtherwiseEmptyMenu() {
render("The Amazing Spider-Man")
menu()
rule.runOnIdle { state.update(state.data.copy(requestsAllowed = false)) }
assertNull(state.sheet)
rule.mainClock.advanceTimeBy(300)
rule.onNodeWithTag("title-menu", useUnmergedTree = true).assertDoesNotExist()
}
@Test fun mediaInfoKeepsTableTextSeparatedInsteadOfAccumulatingOverlap() {
render()
menu()
press(Key.DirectionCenter)
rule.onNodeWithText("Container", useUnmergedTree = true).assertIsDisplayed()
rule.onNodeWithText("Dutch", useUnmergedTree = true).assertIsDisplayed()
val text = rule.onAllNodes(SemanticsMatcher.keyIsDefined(androidx.compose.ui.semantics.SemanticsActions.GetTextLayoutResult) and hasAnyAncestor(hasTestTag("title-sheet")), useUnmergedTree = true)
.fetchSemanticsNodes().map { it.boundsInRoot }.filter { it.width > 0 && it.height > 0 }
text.forEachIndexed { index, first -> text.drop(index + 1).forEach { second ->
if (minOf(first.right, second.right) > maxOf(first.left, second.left)) {
assertTrue("Media info text must never collide vertically: $first vs $second", minOf(first.bottom, second.bottom) <= maxOf(first.top, second.top))
}
} }
}
@Test fun menuToMediaInfoNeverDoubleDimsOrPopsAndBackKeepsOneScrim() {
render { it.copy(item = it.item.copy(backdrop = null, poster = null), cast = emptyList(), related = emptyList()) }
menu()
rule.mainClock.autoAdvance = false
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "title-menu-info-motion").apply { mkdirs() }
rule.runOnIdle { state.clickSheet(0) }
var waited = 0
while (rule.onAllNodesWithTag("title-sheet", useUnmergedTree = true).fetchSemanticsNodes().isEmpty() && waited <= WolfskinTokens.Motion.base + 100) {
rule.mainClock.advanceTimeByFrame()
rule.waitForIdle()
waited += 16
}
val records = mutableListOf<String>()
fun frame(phase: String, index: Int): Pair<Int, Int> {
val path = File(directory, "$phase-${index.toString().padStart(2, '0')}.png")
rule.onRoot().captureRoboImage(path.absolutePath)
val bitmap = BitmapFactory.decodeFile(path.absolutePath)
fun brightness(x: Int, y: Int): Int {
val pixel = bitmap.getPixel(x, y)
return android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)
}
return try {
(brightness(1900, 900) to brightness(100, 900)).also { records.add("$phase,${index * 16},${it.first},${it.second}") }
} finally { bitmap.recycle() }
}
val info = List(14) { index ->
rule.waitForIdle()
frame("info", index).also { rule.mainClock.advanceTimeBy(16); rule.waitForIdle() }
}
info.forEach { assertEquals("No extra scrim or disappearing overlay may darken or pop over Media info", info.last(), it) }
rule.runOnIdle { state.back(shell) }
repeat(14) { index ->
rule.mainClock.advanceTimeBy(16)
rule.waitForIdle()
assertEquals("Returning to Menu keeps exactly one scrim", info.last().second, frame("back", index).second)
}
rule.onNodeWithTag("title-menu", useUnmergedTree = true).assertExists()
File(directory, "samples.csv").writeText("phase,timeMs,sheetBrightness,leftBrightness\n" + records.joinToString("\n"))
rule.mainClock.autoAdvance = true
}
@Test fun menuFadeFramesStayMonotonicAndNeverTranslate() {
render { it.copy(item = it.item.copy(backdrop = null, poster = null), cast = emptyList(), related = emptyList()) }
rule.mainClock.autoAdvance = false
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "title-menu-motion").apply { mkdirs() }
rule.runOnIdle { state.click("header", state.options.indexOfFirst { it.id == "menu" }) }
var waited = 0
while (rule.onAllNodesWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNodes().isEmpty() && waited <= WolfskinTokens.Motion.base + 100) {
rule.mainClock.advanceTimeByFrame()
rule.waitForIdle()
waited += 16
}
val bounds = rule.onNodeWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
val samples = mutableListOf<String>()
fun frame(phase: String, index: Int): Int {
val file = File(directory, "$phase-${index.toString().padStart(2, '0')}.png")
rule.onRoot().captureRoboImage(file.absolutePath)
val image = BitmapFactory.decodeFile(file.absolutePath)
val brightness = try {
val pixel = image.getPixel((bounds.left + 40).toInt(), (bounds.top + 6).toInt())
android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)
} finally { image.recycle() }
samples.add("$phase,${index * 16},$brightness,${bounds.left},${bounds.top}")
return brightness
}
fun assertMonotonic(values: List<Int>) {
assertNotEquals("Fade must visibly change the menu pixel", values.first(), values.last())
val direction = if (values.last() > values.first()) 1 else -1
values.zipWithNext().forEach { (before, after) ->
assertTrue("Menu alpha must not flicker or snap back: $values", (after - before) * direction >= -1)
}
}
val entering = List(14) { index ->
rule.waitForIdle()
assertEquals(bounds, rule.onNodeWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot)
frame("open", index).also { rule.mainClock.advanceTimeBy(16); rule.waitForIdle() }
}
assertMonotonic(entering)
rule.runOnIdle { state.back(shell) }
val leaving = mutableListOf<Int>()
repeat(9) { index ->
rule.waitForIdle()
if (rule.onAllNodesWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty()) leaving.add(frame("close", index))
rule.mainClock.advanceTimeBy(16)
}
assertMonotonic(leaving)
rule.onNodeWithTag("title-menu").assertDoesNotExist()
File(directory, "samples.csv").writeText("phase,timeMs,brightness,leftPx,topPx\n" + samples.joinToString("\n"))
rule.mainClock.autoAdvance = true
press(Key.DirectionCenter)
assertEquals(TitleSheet.Menu, state.sheet)
}
}

View file

@ -19,21 +19,23 @@ import org.robolectric.annotation.Config
@Config(sdk = [34], application = Application::class) @Config(sdk = [34], application = Application::class)
class TitleTrailerTest { class TitleTrailerTest {
private val context = ApplicationProvider.getApplicationContext<Context>() private val context = ApplicationProvider.getApplicationContext<Context>()
private val url = "https://www.youtube.com/watch?v=abcdefghijk" private val url = "https://www.youtube.com/watch?v=ab_-defghij"
@Test fun missingYoutubeHandlerStaysOnTheTitleEvenWhenABrowserExists() { @Test fun missingYoutubeHandlerStaysOnTheTitleEvenWhenABrowserExists() {
register(Intent(Intent.ACTION_VIEW, Uri.parse(url)), "test.browser") register(Intent(Intent.ACTION_VIEW, Uri.parse("https://www.youtube.com/watch?v=ab_-defghij")), "test.browser")
assertFalse(TitleTrailer(context).open(url)) assertFalse(TitleTrailer(context).open(url))
assertNull(shadowOf(context as Application).nextStartedActivity) assertNull(shadowOf(context as Application).nextStartedActivity)
} }
@Test fun installedYoutubeHandlerReceivesAnExplicitPackageAndNoBrowserUrl() { @Test fun installedYoutubeHandlerReceivesAnExplicitPackageAndNoBrowserUrl() {
register(Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube:abcdefghijk")), "test.youtube") register(Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube://ab_-defghij")), "test.youtube")
assertTrue(TitleTrailer(context).open(url)) assertTrue(TitleTrailer(context).open(url))
val launched = shadowOf(context as Application).nextStartedActivity val launched = shadowOf(context as Application).nextStartedActivity
assertEquals(Intent.ACTION_VIEW, launched.action)
assertEquals("test.youtube", launched.`package`) assertEquals("test.youtube", launched.`package`)
assertEquals("vnd.youtube", launched.data?.scheme) assertEquals("vnd.youtube", launched.data?.scheme)
assertEquals("abcdefghijk", launched.data?.schemeSpecificPart) // SmartTube (IntentExtractor.extractVideoId) takes the id from Uri.getHost() for this scheme; the opaque `vnd.youtube:ID` form yields null there.
assertEquals("ab_-defghij", launched.data?.host)
} }
@Test fun nonYoutubeTrailerNeverOpensAGeneralViewHandler() { @Test fun nonYoutubeTrailerNeverOpensAGeneralViewHandler() {

View file

@ -121,7 +121,7 @@ class WatchReadTest {
runCurrent() runCurrent()
assertFalse(loading.isCompleted) assertFalse(loading.isCompleted)
assertTrue(model.snapshot.rows.first().watchReady) assertTrue(model.snapshot.rows.first().watchReady)
assertEquals(1136.274, model.snapshot.rows.single { it.id == "nu" }.cards.single().resumeSeconds, .0001) assertEquals(1136.274, model.snapshot.rows.single { it.id == "cw" }.cards.single().resumeSeconds, .0001)
releaseLibrary.complete(Unit) releaseLibrary.complete(Unit)
loading.join() loading.join()
assertEquals(96.078, model.snapshot.rows.first().cards.first().resumeSeconds, .0001) assertEquals(96.078, model.snapshot.rows.first().cards.first().resumeSeconds, .0001)
@ -132,4 +132,28 @@ class WatchReadTest {
WholphinDispatchers.reset() WholphinDispatchers.reset()
} }
} }
@Test fun mergedHomeUsesSeriesWatchActivityNotAnUnwatchedNextEpisodeDate() = runTest {
val otherSeries = UUID.fromString("00000000-0000-0000-0000-000000000004")
val checkpoint = decode(resumeWire).let { it.copy(seriesId = seriesId, seriesName = "Series A",
userData = it.userData!!.copy(lastPlayedDate = java.time.LocalDateTime.parse("2026-11-05T12:00:00"))) }
val hero = checkpoint.copy(id = UUID.fromString("00000000-0000-0000-0000-000000000005"), type = BaseItemKind.MOVIE, seriesId = null, seriesName = null)
val next = decode(nextWire).let { it.copy(seriesId = seriesId, seriesName = "Series A",
userData = it.userData!!.copy(playbackPositionTicks = 0, lastPlayedDate = null)) }
val otherNext = next.copy(id = UUID.fromString("00000000-0000-0000-0000-000000000006"), seriesId = otherSeries, seriesName = "Series B")
coEvery { items.getResumeItems(userId = userId, fields = any(), mediaTypes = any(), limit = 20) } returns successQueryResult(listOf(hero, checkpoint))
coEvery { shows.getNextUp(userId = userId, fields = any(), limit = 20, enableUserData = true) } returns successQueryResult(listOf(next, otherNext))
for ((id, date) in listOf(seriesId to "2026-11-04T12:00:00", otherSeries to "2026-11-07T12:00:00")) {
coEvery { library.getItem(userId = userId, itemId = id) } returns successResponse(BaseItemDto(id = id, type = BaseItemKind.SERIES))
val watched = checkpoint.copy(userData = checkpoint.userData!!.copy(lastPlayedDate = java.time.LocalDateTime.parse(date)))
coEvery { items.getItems(userId = userId, parentId = id, recursive = true,
includeItemTypes = listOf(BaseItemKind.EPISODE), isPlayed = true, enableUserData = true,
sortBy = listOf(org.jellyfin.sdk.model.api.ItemSortBy.DATE_PLAYED),
sortOrder = listOf(org.jellyfin.sdk.model.api.SortOrder.DESCENDING), limit = 1) } returns successQueryResult(listOf(watched))
}
val rows = buildHomeRows(HomeReadFacade(api, images, mockk()).readWatch(userId), emptyList())
val watching = rows.single { it.id == "cw" }.cards
assertEquals(listOf(otherNext.id.toString(), checkpoint.id.toString()), watching.map { it.id })
assertEquals(96.078, watching.last().resumeSeconds, .0001)
}
} }

View file

@ -0,0 +1,147 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrRatingsService
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.components.LocalSeerrRatingsService
import com.github.damontecres.wholphin.ui.main.settings.HomePageSettingsState
import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsPage
import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsViewModel
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setup.InstallUpdatePage
import com.github.damontecres.wholphin.ui.setup.InstallUpdateState
import com.github.damontecres.wholphin.ui.setup.UpdateViewModel
import com.github.damontecres.wholphin.util.LoadingState
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
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.Shadows
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Real full-screen pages keep their local Back ladder before task-level exit. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class WolfskinBackStackTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private lateinit var activity: ComponentActivity
private val navigation = NavigationManager()
private val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
private val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin") {
when (it) {
ShellAction.Pop -> navigation.goBack()
ShellAction.Exit -> navigation.requestExit()
else -> Unit
}
}.apply { pickerVisible = false; pickerLaunch = false; navFocused = true }
@Before fun initialize() { activity = rule.activity }
@Test fun singletonUpdatesBackFinishesInsteadOfBecomingADeadEnd() {
navigation.replace(Destination.UpdateApp)
val model = updatesModel()
rule.setContent {
rememberWolfskinBackHandler(shell, navigation)
WolfskinTheme { InstallUpdatePage(preferences, viewModel = model) }
}
rule.onNodeWithText("No release found! Check the update URL.").assertIsDisplayed()
back()
assertTrue(activity.isFinishing)
assertEquals(listOf(Destination.UpdateApp), navigation.backStack.toList())
}
@Test fun pushedUpdatesPopsSettingsThenPickerBeforeRootExit() {
navigation.navigateTo(Destination.Wolfskin("settings"))
navigation.navigateTo(Destination.UpdateApp)
shell.enterDestination("settings")
shell.navFocused = false
val model = updatesModel()
rule.setContent {
rememberWolfskinBackHandler(shell, navigation)
WolfskinTheme {
if (navigation.backStack.last() == Destination.UpdateApp) InstallUpdatePage(preferences, viewModel = model)
else ShellSurface(shell) { SettingsList(shell, it) }
}
}
rule.onNodeWithText("No release found! Check the update URL.").assertIsDisplayed()
back()
assertFalse(activity.isFinishing)
assertEquals(listOf(Destination.Home(), Destination.Wolfskin("settings")), navigation.backStack.toList())
back()
assertFalse(activity.isFinishing)
assertTrue(shell.pickerVisible)
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
back()
assertFalse(shell.pickerVisible)
assertTrue(shell.navFocused)
assertFalse(activity.isFinishing)
back()
assertTrue(activity.isFinishing)
}
@Test fun unsavedHomeSettingsPromptsBeforeDiscardCanExitTheTask() {
navigation.replace(Destination.HomeSettings)
val model = mockk<HomeSettingsViewModel>(relaxed = true)
every { model.state } returns MutableStateFlow(HomePageSettingsState.EMPTY.copy(loading = LoadingState.Success))
every { model.serverPluginActive } returns MutableStateFlow(false)
every { model.settingsChanged } returns MutableStateFlow(true)
every { model.navigationManager } returns navigation
every { model.discardChanges() } returns Job().apply { complete() }
val images = mockk<ImageUrlService>(relaxed = true)
val ratings = SeerrRatingsService(mockk(relaxed = true), mockk(relaxed = true))
rule.setContent {
rememberWolfskinBackHandler(shell, navigation)
WolfskinTheme {
CompositionLocalProvider(LocalSeerrRatingsService provides ratings, LocalImageUrlService provides images) {
HomeSettingsPage(preferences, Modifier.fillMaxSize(), viewModel = model)
}
}
}
back()
assertFalse("Back must not discard unsaved settings or finish the task", activity.isFinishing)
assertEquals(listOf(Destination.HomeSettings), navigation.backStack.toList())
val discard = rule.onNodeWithText("Discard").assertIsDisplayed().assertIsEnabled()
discard.performSemanticsAction(SemanticsActions.RequestFocus) { it() }
discard.assertIsFocused().performKeyInput { pressKey(Key.DirectionCenter) }
rule.waitUntil(10_000) {
Snapshot.sendApplyNotifications()
Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
activity.isFinishing
}
assertEquals(listOf(Destination.HomeSettings), navigation.backStack.toList())
}
private fun updatesModel(): UpdateViewModel = mockk<UpdateViewModel>(relaxed = true).also { model ->
every { model.state } returns MutableStateFlow(InstallUpdateState(loading = LoadingState.Success))
every { model.navigationManager } returns navigation
every { model.updater.hasPermissions() } returns true
}
private fun back() {
rule.runOnIdle { activity.onBackPressedDispatcher.onBackPressed() }
rule.mainClock.advanceTimeBy(400)
if (!activity.isFinishing) rule.waitForIdle()
}
}

View file

@ -0,0 +1,184 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinServerUsers
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.nav.Destination
import io.mockk.coEvery
import io.mockk.mockk
import java.util.UUID
import kotlinx.coroutines.cancel
import org.junit.After
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.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Actual Wolfskin NavDisplay and Android Back finish the activity instead of exposing upstream setup. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class WolfskinRootExitTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private lateinit var activity: ComponentActivity
private lateinit var model: WolfskinShellViewModel
private lateinit var conductarr: ConductarrSession
private val navigation = NavigationManager()
private val session = WolfskinSession()
private var oldFixtures = false
private var oldScreens = true
@Before fun initialize() {
activity = rule.activity
ConductarrPreference.initialize(activity)
WolfskinScreenPreference.initialize(activity)
oldFixtures = ConductarrPreference.fixtures
oldScreens = WolfskinScreenPreference.enabled
ConductarrPreference.updateFixtures(true)
WolfskinScreenPreference.enabled = true
}
@After fun cleanup() {
if (::model.isInitialized) model.viewModelScope.cancel()
if (::conductarr.isInitialized) conductarr.close()
ConductarrPreference.updateFixtures(oldFixtures)
WolfskinScreenPreference.enabled = oldScreens
}
@Test fun homeContentThenNavBackFinishesTheActivity() = verifyRoot("home")
@Test fun discoverContentThenNavBackFinishesTheActivity() = verifyRoot("discover")
@Test fun moviesContentThenNavBackFinishesTheActivity() = verifyRoot("movies")
@Test fun showsContentThenNavBackFinishesTheActivity() = verifyRoot("shows")
@Test fun animeContentThenNavBackFinishesTheActivity() = verifyRoot("anime")
@Test fun hardwareBackUsesTheSameContentNavExitLadder() {
mount()
val shell = session.state!!
rule.runOnIdle { shell.pickerVisible = false; shell.pickerLaunch = false }
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Back) }
assertTrue(shell.navFocused)
assertFalse(activity.isFinishing)
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Back) }
rule.mainClock.advanceTimeBy(400)
assertTrue(activity.isFinishing)
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
}
@Test fun coldPickerBackFinishesWithoutSelectingAnAvatarOrShowingUpstreamUi() {
mount()
assertTrue(session.state!!.pickerLaunch)
back()
assertTrue("Back from the cold picker must finish the activity", activity.isFinishing)
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
}
@Test fun reopenedPickerBackDismissesToNavAndTheFollowingBackExits() {
mount()
val shell = session.state!!
rule.runOnIdle { shell.pickerVisible = false; shell.focusNav(shell.tabs.size); shell.key(ShellKey.Ok) }
assertTrue(shell.pickerVisible)
back()
assertFalse(shell.pickerVisible)
assertTrue(shell.navFocused)
assertFalse(activity.isFinishing)
back()
assertTrue(activity.isFinishing)
}
@Test fun comingInSheetDismissesBeforeContentNavAndExit() {
mount()
val shell = session.state!!
rule.runOnIdle {
shell.pickerVisible = false
shell.pickerLaunch = false
shell.navFocused = false
shell.openRequests()
}
assertTrue(shell.requestsVisible)
back()
assertFalse(shell.requestsVisible)
assertFalse(shell.navFocused)
assertFalse(activity.isFinishing)
back()
assertTrue(shell.navFocused)
assertFalse(activity.isFinishing)
back()
assertTrue(activity.isFinishing)
}
private fun verifyRoot(route: String) {
mount()
val shell = session.state!!
val destination = if (route == "home") Destination.Home() else Destination.Wolfskin(route)
rule.runOnIdle {
shell.pickerVisible = false
shell.pickerLaunch = false
shell.enterDestination(route)
navigation.replace(destination)
}
rule.mainClock.advanceTimeBy(1200)
rule.waitForIdle()
rule.waitUntil(10_000) {
Snapshot.sendApplyNotifications()
Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
shell.contentFocus?.hasContent == true &&
(route !in LIBRARY_DESTINATIONS || shell.rememberedView(route).library?.status == LibraryStatus.Ready)
}
rule.runOnIdle { shell.navFocused = false }
back()
assertFalse("The first Back must not finish $route content", activity.isFinishing)
assertTrue("The first Back must move $route content to nav", shell.navFocused)
assertEquals(listOf(destination), navigation.backStack.toList())
back()
assertTrue("Back from $route nav must finish the activity", activity.isFinishing)
assertEquals("Exit cannot detour to old Home", listOf(destination), navigation.backStack.toList())
}
private fun mount() {
val server = JellyfinServer(UUID.randomUUID(), "Fixture", "https://example.invalid", null)
val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = null)
val repository = mockk<ServerRepository>(relaxed = true)
coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, listOf(user))
val libraries = mockk<LibraryReadFacade>(relaxed = true)
coEvery { libraries.readDestinations(user.id) } returns emptyMap()
conductarr = ConductarrSession(MemoryCacheStore())
model = WolfskinShellViewModel(activity, session, repository, navigation, mockk(relaxed = true),
mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
val home = HomeFixture(activity).snapshot("justin")
rule.setContent {
WolfskinApp(server, user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = model,
homeContent = { shell, _, _, modifier -> HomeScreen(shell, home, modifier) {} })
}
rule.waitUntil(10_000) {
Snapshot.sendApplyNotifications()
Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
session.state?.people?.size == 3 && model.signedInPerson == "justin"
}
rule.waitForIdle()
}
private fun back() {
rule.runOnIdle { activity.onBackPressedDispatcher.onBackPressed() }
rule.mainClock.advanceTimeBy(400)
if (!activity.isFinishing) rule.waitForIdle()
}
}

View file

@ -0,0 +1,166 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.content.Intent
import android.os.Looper
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.snapshots.Snapshot
import org.robolectric.Shadows
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.datastore.core.DataStore
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import com.github.damontecres.wholphin.MainActivityViewModel
import com.github.damontecres.wholphin.MainContent
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinServerUsers
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.*
import com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.coVerify
import java.io.File
import java.io.IOException
import java.util.UUID
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.runBlocking
import org.junit.After
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.conductarr.MemoryCacheStore
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Exercise the actual MainContent boundary, not a parallel copy of its destination mapping. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class WolfskinSetupNavigationTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null)
private val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "test-token")
private val repository = mockk<ServerRepository>(relaxed = true)
private val navigation = NavigationManager()
private val setup = mockk<SetupNavigationManager>(relaxed = true)
private val stack = mutableStateListOf<SetupDestination>(SetupDestination.Loading)
private val conductarr = ConductarrSession(MemoryCacheStore())
private val model = WolfskinSetupViewModel(repository, navigation, setup, WolfskinSession(), conductarr)
private val preferences = mockk<DataStore<AppPreferences>>()
private val intentService = mockk<IntentService>()
private var previousScreens = true
private var previousFixtures = false
@Before fun configure() {
WolfskinScreenPreference.initialize(rule.activity)
previousScreens = WolfskinScreenPreference.enabled
WolfskinScreenPreference.enabled = true
ConductarrPreference.initialize(rule.activity)
previousFixtures = ConductarrPreference.fixtures
ConductarrPreference.updateFixtures(false)
every { repository.current } returns MutableStateFlow<CurrentUser?>(null)
coEvery { repository.getMostRecentServer() } returns RestoredSession.ServerOnly(server)
every { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, listOf(user))
every { repository.serverDao.getServers() } returns listOf(JellyfinServerUsers(server, listOf(user)))
every { setup.navigateTo(any()) } answers { stack[0] = firstArg() }
every { preferences.data } returns MutableStateFlow(AppPreferences.getDefaultInstance())
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
}
@After fun close() {
conductarr.close()
WolfskinScreenPreference.enabled = previousScreens
ConductarrPreference.updateFixtures(previousFixtures)
}
private fun mount() {
val screensaver = mockk<ScreensaverService>(relaxed = true)
every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false))
rule.setContent {
MainContent(stack, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
mockk(relaxed = true), screensaver, Modifier,
setupInterceptor = { destination, modifier -> WolfskinSetupContent(destination, modifier, model) })
}
}
private fun appModel() = MainActivityViewModel(rule.activity, preferences, repository, setup, navigation,
mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), intentService)
private fun awaitStage(stage: WolfskinSetupViewModel.Stage) {
rule.waitUntil(5_000) {
Snapshot.sendApplyNotifications()
Shadows.shadowOf(Looper.getMainLooper()).idle()
model.stage == stage
}
}
private fun assertOwnPicker() {
awaitStage(WolfskinSetupViewModel.Stage.Picker)
rule.onNodeWithText("Who’s watching?").assertIsDisplayed()
rule.onNodeWithText("Justin").assertIsDisplayed()
rule.onNodeWithText(rule.activity.getString(R.string.select_server)).assertDoesNotExist()
rule.onNodeWithText(rule.activity.getString(R.string.select_user)).assertDoesNotExist()
}
@Test fun coldStartLegacySignOutDrawerSwitchAndIntentFailureNeverComposeStockSelectors() {
runBlocking { appModel().handleAppStart() }
mount()
assertOwnPicker()
// The legacy sign-out-equivalent erased the recent preference, but Room still has the household.
coEvery { repository.getMostRecentServer() } returns RestoredSession.None
rule.runOnIdle { setup.navigateTo(SetupDestination.ServerList) }
assertOwnPicker()
rule.runOnIdle { setup.navigateTo(SetupDestination.UserList(server)) }
assertOwnPicker()
coEvery { intentService.parseIntent(any()) } returns IntentResult.Error("Cannot restore sign-in")
rule.runOnIdle { rule.activity.lifecycleScope.launch { appModel().handleIntent(Intent()) } }
rule.waitUntil(5_000) { stack.last() == SetupDestination.ServerList }
assertOwnPicker()
coVerify(exactly = 0) { repository.switchServerOrUser() }
}
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Test fun unreachableSelectionRendersWolfskinRecoveryFrame() {
stack[0] = SetupDestination.UserList(server)
coEvery { repository.changeUser(server, user) } throws IOException("offline")
mount()
assertOwnPicker()
rule.onNodeWithText("Justin").performClick()
awaitStage(WolfskinSetupViewModel.Stage.Unavailable)
rule.onNodeWithText("Try again").assertIsDisplayed()
rule.onNodeWithText("Sign in").assertIsDisplayed()
rule.onNodeWithText(rule.activity.getString(R.string.select_user)).assertDoesNotExist()
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "setup").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "household-unreachable.png"))
}
@Test fun unconfiguredFirstRunOffersUrlInsteadOfStockServerSelector() {
coEvery { repository.getMostRecentServer() } returns RestoredSession.None
every { repository.serverDao.getServers() } returns emptyList()
stack[0] = SetupDestination.ServerList
mount()
awaitStage(WolfskinSetupViewModel.Stage.Unconfigured)
rule.onNodeWithText("Connect your household server").assertIsDisplayed()
rule.onNodeWithTag("household-server-url").assertIsDisplayed().performTextInput("https://jellyfin.test")
rule.onNodeWithText("Try again").assertIsEnabled()
rule.onNodeWithText(rule.activity.getString(R.string.select_server)).assertDoesNotExist()
}
}

View file

@ -0,0 +1,122 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.content.pm.ActivityInfo
import android.os.Looper
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.core.app.ApplicationProvider
import com.github.damontecres.wholphin.MainContent
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.ScreensaverState
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.test.TestActivity
import com.github.damontecres.wholphin.ui.TestModule
import com.github.damontecres.wholphin.ui.successResponse
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.configure
import com.github.damontecres.wholphin.util.reset
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.HiltTestApplication
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import java.util.UUID
import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.quickConnectApi
import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.operations.QuickConnectApi
import org.jellyfin.sdk.api.operations.SystemApi
import org.jellyfin.sdk.api.operations.UserApi
import org.jellyfin.sdk.model.api.PublicSystemInfo
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestWatcher
import org.junit.runner.Description
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
/** The rollout switch must still expose both real upstream selectors, not a second forked copy. */
@OptIn(ExperimentalCoroutinesApi::class)
@HiltAndroidTest
@Config(application = HiltTestApplication::class, sdk = [34])
@RunWith(RobolectricTestRunner::class)
class WolfskinSetupRollbackTest {
@get:Rule(order = 0) val hilt = HiltAndroidRule(this)
@get:Rule(order = 1) val registerActivity = object : TestWatcher() {
override fun starting(description: Description?) {
val context = ApplicationProvider.getApplicationContext<Application>()
shadowOf(context.packageManager).addOrUpdateActivity(ActivityInfo().apply {
name = TestActivity::class.java.name
packageName = context.packageName
})
}
}
@get:Rule(order = 2) val rule = createAndroidComposeRule<TestActivity>()
@Inject lateinit var api: ApiClient
@Inject lateinit var jellyfin: Jellyfin
private var previousScreens = true
@Before fun setup() {
hilt.inject()
WholphinDispatchers.configure(TestModule.testDispatcher)
WolfskinScreenPreference.initialize(rule.activity)
previousScreens = WolfskinScreenPreference.enabled
WolfskinScreenPreference.enabled = false
val users = mockk<UserApi>()
val system = mockk<SystemApi>()
val quickConnect = mockk<QuickConnectApi>()
every { jellyfin.createApi(any(), any(), any(), any(), any()) } returns api
every { api.userApi } returns users
every { api.systemApi } returns system
every { api.quickConnectApi } returns quickConnect
coEvery { users.getPublicUsers() } returns successResponse(emptyList())
coEvery { system.getPublicSystemInfo() } returns successResponse(PublicSystemInfo(version = "10.11.11"))
coEvery { quickConnect.getQuickConnectEnabled() } returns successResponse(false)
}
@After fun restore() {
WolfskinScreenPreference.enabled = previousScreens
WholphinDispatchers.reset()
}
@Test fun rollbackRendersBothStockSelectors() {
val stack = mutableStateListOf<SetupDestination>(SetupDestination.ServerList)
val screensaver = mockk<ScreensaverService>()
every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false))
rule.setContent {
MainContent(stack, NavigationManager(), UserPreferences(AppPreferences.getDefaultInstance(), null),
mockk(relaxed = true), screensaver)
}
fun awaitHeading(resource: Int) {
rule.waitUntil(5_000) {
TestModule.testDispatcher.scheduler.advanceUntilIdle()
shadowOf(Looper.getMainLooper()).idle()
rule.onAllNodes(androidx.compose.ui.test.hasText(rule.activity.getString(resource))).fetchSemanticsNodes().isNotEmpty()
}
rule.onNodeWithText(rule.activity.getString(resource)).assertIsDisplayed()
rule.onNodeWithText("Who’s watching?").assertDoesNotExist()
}
awaitHeading(R.string.select_server)
rule.runOnIdle { stack[0] = SetupDestination.UserList(JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null)) }
awaitHeading(R.string.select_user)
}
}

View file

@ -0,0 +1,238 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import com.github.damontecres.wholphin.data.AppDatabase
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.MostRecentServer
import com.github.damontecres.wholphin.data.MostRecentServerSharedPreferences
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
import com.github.damontecres.wholphin.ui.successResponse
import com.github.damontecres.wholphin.ui.TestModule
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import java.io.IOException
import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.async
import kotlinx.coroutines.test.runTest
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.operations.SystemApi
import org.jellyfin.sdk.api.operations.UserApi
import org.jellyfin.sdk.discovery.RecommendedServerInfo
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
import org.jellyfin.sdk.model.api.AuthenticationResult
import org.jellyfin.sdk.model.api.PublicSystemInfo
import org.jellyfin.sdk.model.api.UserDto
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.tv.WolfskinSetupViewModel.Stage
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Setup preserves saved credentials, rejects protected-profile bypasses and recovers real auth failures. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class WolfskinSetupTest {
private val context get() = ApplicationProvider.getApplicationContext<Application>()
private val database by lazy { Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).allowMainThreadQueries().build() }
private val recent by lazy { MostRecentServerSharedPreferences(context) }
private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null)
private var user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "saved-test-token")
private val api = mockk<ApiClient>(relaxed = true)
private val usersApi = mockk<UserApi>()
private val systemApi = mockk<SystemApi>()
private val jellyfin by lazy { TestModule.jellyfin(mockk(), context, ClientInfo("Wolfskin test", "test"), DeviceInfo("setup-test", "Setup test")) }
private val navigation = NavigationManager()
private val setup = mockk<SetupNavigationManager>(relaxed = true)
private val session = WolfskinSession()
private val conductarr = ConductarrSession(MemoryCacheStore())
private val repository by lazy { ServerRepository(context, jellyfin, database.serverDao(), api, Dispatchers.IO, recent) }
private val model by lazy { WolfskinSetupViewModel(repository, navigation, setup, session, conductarr) }
private val dto get() = UserDto(id = user.id, name = user.name, hasPassword = true, hasConfiguredPassword = true, hasConfiguredEasyPassword = false)
private var previousFixtures = false
@Before fun saveHousehold(): Unit = runBlocking {
ConductarrPreference.initialize(context)
previousFixtures = ConductarrPreference.fixtures
ConductarrPreference.updateFixtures(false)
recent.clear()
database.serverDao().addOrUpdateServer(server)
user = database.serverDao().addOrUpdateUser(user)
recent.save(CurrentUser(server, user))
val client = jellyfin
every { api.userApi } returns usersApi
every { api.systemApi } returns systemApi
every { client.createApi(any(), any(), any(), any(), any()) } returns api
coEvery { usersApi.getCurrentUser() } returns successResponse(dto)
coEvery { systemApi.getPublicSystemInfo() } returns successResponse(PublicSystemInfo(id = server.id.toString(), serverName = server.name))
}
@After fun close() {
conductarr.close()
database.close()
ConductarrPreference.updateFixtures(previousFixtures)
}
@Test fun enteringPickerPreservesServerAndUserAndRecoversErasedPreferenceFromRoom() = runBlocking {
val saved = recent.get()
model.load(null)
assertEquals(Stage.Picker, model.stage)
assertEquals(user.id.toString(), model.picker!!.person.jellyfinUserId)
assertEquals(saved, recent.get())
recent.clear()
model.load(null)
assertEquals(server, model.server)
assertEquals(Stage.Picker, model.stage)
assertEquals(MostRecentServer.None, recent.get())
}
@Test fun expiredTokenRequiresCredentialsAndSuccessfulLoginRestoresSessionWithoutSecondPicker() = runBlocking {
model.load(server)
coEvery { usersApi.getCurrentUser() } throws InvalidStatusException(401)
model.select(model.picker!!.person)
assertEquals(Stage.Login, model.stage)
assertEquals(user, model.loginUser)
assertNull(repository.current.value)
assertTrue(recent.get() is MostRecentServer.ServerAndUser)
coEvery { usersApi.authenticateUserByName(any()) } returns successResponse(AuthenticationResult(user = dto, serverId = server.id.toString(), accessToken = "renewed-test-token"))
coEvery { usersApi.getCurrentUser() } returns successResponse(dto)
model.login(user.name!!, "test-password")
assertEquals("renewed-test-token", repository.current.value!!.user.accessToken)
assertEquals(user.id, (recent.get() as MostRecentServer.ServerAndUser).userId)
assertFalse(session.state!!.pickerVisible)
assertEquals(user.id.toString(), session.state!!.person.jellyfinUserId)
}
@Test fun unreachableServerOffersRetryAndSavedPersonCanSignInWhenItReturns() = runTest {
model.load(server)
val saved = recent.get()
coEvery { usersApi.getCurrentUser() } throws IOException("offline")
model.select(model.picker!!.person)
assertEquals(Stage.Unavailable, model.stage)
assertEquals(saved, recent.get())
val retry = async(start = CoroutineStart.UNDISPATCHED) { model.retry() }
assertEquals(Stage.Loading, model.stage)
retry.await()
assertEquals(Stage.Picker, model.stage)
coEvery { usersApi.getCurrentUser() } returns successResponse(dto)
model.select(model.picker!!.person)
assertEquals(user.id, repository.current.value!!.user.id)
assertFalse(session.state!!.pickerVisible)
}
@Test fun retryCanRemainUnavailableWithoutErasingHousehold() = runTest {
database.serverDao().deleteUser(server.id, user.id)
val saved = recent.get()
coEvery { usersApi.getPublicUsers() } throws IOException("offline")
model.load(null)
assertEquals(Stage.Unavailable, model.stage)
val retry = async(start = CoroutineStart.UNDISPATCHED) { model.retry() }
assertEquals(Stage.Loading, model.stage)
retry.await()
assertEquals(Stage.Unavailable, model.stage)
assertEquals(server, model.server)
assertEquals(saved, recent.get())
}
@Test fun pinAndRequireLoginCannotReuseSavedTokenWithoutUnlocking() = runBlocking {
user = user.copy(pin = "URDL")
database.serverDao().updateUser(user)
model.load(server)
model.select(model.picker!!.person)
assertEquals(user, model.pinUser)
assertTrue(model.picker!!.pickerVisible)
model.enterPin("WRONG")
assertNull(repository.current.value)
model.enterPin("URDL")
assertEquals(user.id, repository.current.value!!.user.id)
user = user.copy(pin = null, requireLogin = true)
database.serverDao().updateUser(user)
model.load(server)
model.select(model.picker!!.person)
assertEquals(Stage.Login, model.stage)
assertEquals(user, model.loginUser)
}
@Test fun unavailablePublicProfilesKeepHouseholdAndAllowCredentialRecovery() = runBlocking {
database.serverDao().deleteUser(server.id, user.id)
coEvery { usersApi.getPublicUsers() } throws IOException("offline")
model.load(null)
assertEquals(Stage.Unavailable, model.stage)
assertEquals(server, model.server)
model.showLogin(null)
coEvery { usersApi.authenticateUserByName(any()) } returns successResponse(AuthenticationResult(
user = dto, serverId = server.id.toString(), accessToken = "new-test-token"))
model.login(user.name!!, "test-password")
assertEquals(user.id, repository.current.value!!.user.id)
}
@Test fun expiredLaunchRecoveryIgnoresOutagesLateIdentitiesAndRollbackMode() = runBlocking {
WolfskinScreenPreference.initialize(context)
val previous = WolfskinScreenPreference.enabled
try {
WolfskinScreenPreference.enabled = true
val expected = (repository.restoreSavedSession() as com.github.damontecres.wholphin.data.RestoredSession.Success).currentUser
var destination: SetupDestination = SetupDestination.AppContent(expected)
every { setup.navigateTo(any()) } answers { destination = firstArg() }
recoverWolfskinSession(context, IOException("offline"), expected, repository, setup)
assertTrue(destination is SetupDestination.AppContent)
WolfskinScreenPreference.enabled = false
recoverWolfskinSession(context, InvalidStatusException(401), expected, repository, setup)
assertTrue(destination is SetupDestination.AppContent)
WolfskinScreenPreference.enabled = true
val other = database.serverDao().addOrUpdateUser(user.copy(rowId = 0, id = UUID.randomUUID(), name = "Mindel"))
recent.save(CurrentUser(server, other))
repository.restoreSavedSession()
recoverWolfskinSession(context, InvalidStatusException(401), expected, repository, setup)
assertTrue(destination is SetupDestination.AppContent)
recoverWolfskinSession(context, InvalidStatusException(401), repository.current.value, repository, setup)
model.load((destination as SetupDestination.UserList).server)
assertEquals(Stage.Picker, model.stage)
assertTrue(model.picker!!.people.any { it.jellyfinUserId == other.id.toString() })
assertEquals(other.id, (recent.get() as MostRecentServer.ServerAndUser).userId)
} finally { WolfskinScreenPreference.enabled = previous }
}
@Test fun firstRunDiscoversAndPersistsHouseholdBeforeFetchingPublicProfiles() = runBlocking {
database.serverDao().deleteUser(server.id, user.id)
database.serverDao().deleteServer(server.id)
recent.clear()
model.load(null)
assertEquals(Stage.Unconfigured, model.stage)
coEvery { jellyfin.discovery.getRecommendedServers("bad-url") } returns emptyList()
model.connect("bad-url")
assertEquals(Stage.Unconfigured, model.stage)
assertTrue(database.serverDao().getServers().isEmpty())
coEvery { jellyfin.discovery.getRecommendedServers(server.url) } returns listOf(RecommendedServerInfo(
address = server.url, responseTime = 1, score = RecommendedServerInfoScore.GREAT, issues = emptyList(),
systemInfo = Result.success(PublicSystemInfo(id = server.id.toString(), serverName = server.name, startupWizardCompleted = true))))
model.connect(server.url)
assertEquals(server.id, database.serverDao().getServers().single().server.id)
coEvery { usersApi.getPublicUsers() } returns successResponse(listOf(dto))
model.load(model.server)
assertEquals(Stage.Picker, model.stage)
model.select(model.picker!!.person)
assertEquals(Stage.Login, model.stage)
assertNull(repository.current.value)
}
}

View file

@ -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<Map<String, List<String>>>()
private val platform = Platform()
private inner class Platform : UpdatePlatform {
var archive = ApkIdentity(UpdateChannel.PACKAGE, InstalledVersion("1.1.1", 100001), setOf(UpdateChannel.SIGNER))
var installed = ApkIdentity(UpdateChannel.PACKAGE, InstalledVersion("1.1.0", 65), setOf(UpdateChannel.SIGNER))
var permission = true
var settingsAvailable = true
var installs = 0
var settingsOpens = 0
var inspectAction: (() -> Unit)? = null
var outcome = InstallOutcome.Cancelled
override fun installed() = installed
override fun inspect(file: File): ApkIdentity { inspectAction?.invoke(); return archive }
override fun hasInstallPermission() = permission
override fun openPermissionSettings(): Boolean { settingsOpens++; return settingsAvailable }
override fun install(file: File, asset: UpdateAsset, outcome: (InstallOutcome) -> Unit) { installs++; outcome(this.outcome) }
}
private val preferences = object : UpdatePreferences {
override val autoCheck = MutableStateFlow(true)
override suspend fun url() = "$base/releases/latest"
override suspend fun enabled() = autoCheck.value
override suspend fun setAutoCheck(enabled: Boolean) { autoCheck.value = enabled }
}
@Before fun start() {
directory = temporary.newFolder()
server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
base = "http://127.0.0.1:${server.address.port}"
server.createContext("/") { exchange ->
synchronized(requests) { requests += exchange.requestHeaders.mapValues { it.value.toList() } }
val path = exchange.requestURI.path
val status = when (path) {
"/releases/latest" -> releaseStatus
"/releases" -> listStatus
"/apk" -> assetStatus
else -> 200
}
val content = when (path) {
"/releases/latest", "/redirected" -> if (invalidJson) "not json" else """{"name":"v1.1.1","body":"Household notes","published_at":"2026-11-07T12:00:00Z","assets":[{"name":"wolfskin-update.json","browser_download_url":"$base/manifest"},{"name":"Wolfskin-armeabi-v7a.apk","browser_download_url":"$base/apk"}]}"""
"/manifest" -> UpdateManifest.encode(manifest())
"/releases" -> listBody
else -> ""
}
if (redirect && path == "/releases/latest") {
exchange.responseHeaders.add("Location", redirectTarget ?: "$base/redirected")
exchange.responseHeaders.add("Set-Cookie", "private-session=never-forward")
exchange.sendResponseHeaders(302, -1)
} else {
val bytes = if (path == "/apk") (if (truncated) apk.copyOf(apk.size / 2) else apk) else content.toByteArray()
val length = when {
chunked && path == "/apk" -> 0L
wireTruncated && path == "/apk" -> apk.size.toLong()
metadataTruncated && path == "/releases/latest" -> bytes.size + 1L
else -> bytes.size.toLong()
}
exchange.sendResponseHeaders(status, length)
exchange.responseBody.use { it.write(bytes) }
}
exchange.close()
}
server.start()
}
@After fun stop() { server.stop(0) }
private fun manifest() = UpdateManifest(1, UpdateChannel.PACKAGE, "1.1.1", code,
"2026-11-07T12:00:00Z", "a".repeat(40), "pwolfskin-opt-v1.1.1", manifestSigner,
listOf(UpdateAsset(if (unsupported) "x86_64" else "armeabi-v7a", if (unsupported) "Wolfskin-x86_64.apk" else "Wolfskin-armeabi-v7a.apk", apk.size.toLong(),
if (badHash) "0".repeat(64) else MessageDigest.getInstance("SHA-256").digest(apk).joinToString("") { "%02x".format(it) }, 23)))
private fun controller(scope: kotlinx.coroutines.CoroutineScope) = HouseholdUpdateController(scope, preferences, platform,
UpdateTransport(allowLocalTestHttp = true), VerifiedUpdateStore(directory, platform), listOf("armeabi-v7a"), 30)
@Test fun newerReleaseDownloadsVerifiesAndRestoresWithoutInstalling() = runTest {
val controller = controller(this)
controller.check(true); advanceUntilIdle()
assertTrue(controller.state.value is UpdateUiState.Available)
val metadata = controller.release.value
assertEquals(controller.state.value, metadata)
controller.download(); advanceUntilIdle()
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
assertEquals(metadata, controller.release.value)
assertEquals(0, platform.installs)
val restored = controller(this); advanceUntilIdle()
assertEquals(UpdateUiState.Ready("1.1.1"), restored.state.value)
assertEquals(metadata, restored.release.value)
restored.install(); advanceUntilIdle()
assertEquals(UpdateUiState.InstallCancelled, restored.state.value)
assertEquals(1, platform.installs)
assertTrue(File(directory, "verified.apk").exists())
}
@Test fun existingManifestOnlyReadyRecordRemainsReadable() = runTest {
val old = controller(this); old.check(true); advanceUntilIdle(); old.download(); advanceUntilIdle()
val metadata = old.release.value!!
assertEquals(manifest(), UpdateManifest.parse(File(directory, "ready.json").readText()))
File(directory, "ready.json").writeText(UpdateManifest.encode(manifest()))
val recreated = controller(this); advanceUntilIdle()
assertEquals(UpdateUiState.Ready("1.1.1"), recreated.state.value)
assertEquals(metadata.copy(notes = ""), recreated.release.value)
assertEquals(0, platform.installs)
}
@Test fun equalCodeIsUpToDateAndOlderCodeIsNotASuccess() = runTest {
code = 65
val equal = controller(this); equal.check(true); advanceUntilIdle()
assertEquals(UpdateUiState.UpToDate, equal.state.value)
code = 64
val older = controller(this); older.check(true); advanceUntilIdle()
assertEquals(UpdateFailure.LowerVersionCode, (older.state.value as UpdateUiState.Failed).kind)
}
@Test fun emptyChannelIsDistinctFromMissingOrRefusedEndpoint() = runTest {
releaseStatus = 404
val empty = controller(this); empty.check(true); advanceUntilIdle()
assertEquals(UpdateUiState.NoReleasePublished, empty.state.value)
for (status in listOf(401, 403, 404, 500, 503)) {
listStatus = status
val failure = controller(this); failure.check(true); advanceUntilIdle()
assertEquals(UpdateFailure.HttpRefused, (failure.state.value as UpdateUiState.Failed).kind)
}
}
@Test fun refusedLatestNeverBecomesUpToDate() = runTest {
for (status in listOf(401, 403, 500, 503)) {
releaseStatus = status
val controller = controller(this); controller.check(true); advanceUntilIdle()
assertEquals(UpdateFailure.HttpRefused, (controller.state.value as UpdateUiState.Failed).kind)
}
}
@Test fun invalidJsonAndUnavailableAbiAreTypedFailures() = runTest {
invalidJson = true
val invalid = controller(this); invalid.check(true); advanceUntilIdle()
assertEquals(UpdateFailure.InvalidMetadata, (invalid.state.value as UpdateUiState.Failed).kind)
invalidJson = false; unsupported = true
val unsupported = controller(this); unsupported.check(true); advanceUntilIdle()
assertEquals(UpdateFailure.UnsupportedAbi, (unsupported.state.value as UpdateUiState.Failed).kind)
}
@Test fun manifestSignerDifferentFromInstallationFailsBeforeDownload() = runTest {
manifestSigner = "b".repeat(64)
val controller = controller(this)
controller.check(true); advanceUntilIdle()
assertEquals(UpdateFailure.WrongSigner, (controller.state.value as UpdateUiState.Failed).kind)
assertFalse(File(directory, "verified.apk").exists())
assertEquals(0, platform.installs)
}
@Test fun checksumAndLengthFailuresNeverCommitReady() = runTest {
for (badChecksum in listOf(true, false)) {
badHash = badChecksum; truncated = !badChecksum
val controller = controller(this); controller.check(true); advanceUntilIdle()
controller.download(); advanceUntilIdle()
assertEquals(UpdateFailure.ChecksumMismatch, (controller.state.value as UpdateUiState.Failed).kind)
assertFalse(File(directory, "ready.json").exists())
assertFalse(File(directory, "download.part").exists())
}
}
@Test fun wrongPackageSignerAndArchiveCodeCannotBecomeReady() = runTest {
val original = platform.archive
for ((archive, expected) in listOf(
original.copy(packageId = "other.package") to UpdateFailure.WrongPackage,
original.copy(signers = setOf("b".repeat(64))) to UpdateFailure.WrongSigner,
original.copy(version = InstalledVersion("1.1.1", 65)) to UpdateFailure.LowerVersionCode,
)) {
platform.archive = archive
val controller = controller(this); controller.check(true); advanceUntilIdle()
controller.download(); advanceUntilIdle()
assertEquals(expected, (controller.state.value as UpdateUiState.Failed).kind)
assertFalse(File(directory, "ready.json").exists())
}
}
@Test fun chunkedTransferReportsUnknownTotalAndCancelDeletesPartial() = runTest {
chunked = true
val controller = controller(this)
var observedUnknownLength = false
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
controller.state.collect { state ->
if (state is UpdateUiState.Downloading && state.bytesDone > 0) {
observedUnknownLength = state.bytesTotal == null
controller.cancel()
}
}
}
controller.check(true); advanceUntilIdle()
controller.download(); advanceUntilIdle()
assertTrue(observedUnknownLength)
assertTrue(controller.state.value is UpdateUiState.Available)
assertFalse(File(directory, "download.part").exists())
assertFalse(File(directory, "ready.json").exists())
}
@Test fun redirectRequestsContainNoCredentialsAndRejectUrlUserInfo() = runTest {
redirect = true
redirectTarget = "http://localhost:${server.address.port}/redirected"
val controller = controller(this); controller.check(true); advanceUntilIdle()
assertTrue(controller.state.value is UpdateUiState.Available)
assertTrue(requests.size >= 3)
requests.forEach { headers ->
assertFalse(headers.keys.any { it.equals("Authorization", true) || it.equals("Cookie", true) ||
it.contains("Jellyfin", true) || it.contains("Emby", true) || it.contains("MediaBrowser", true) })
}
redirectTarget = "http://user:password@127.0.0.1:${server.address.port}/redirected"
val rejected = controller(this); rejected.check(true); advanceUntilIdle()
assertEquals(UpdateFailure.InvalidMetadata, (rejected.state.value as UpdateUiState.Failed).kind)
}
@Test fun manualCheckWorksWithAutoOffAndPermissionDenialKeepsReady() = runTest {
preferences.autoCheck.value = false
val controller = controller(this)
controller.check(false); advanceUntilIdle()
assertEquals(UpdateUiState.Idle, controller.state.value)
controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
platform.permission = false
controller.install(); advanceUntilIdle()
assertEquals(UpdateUiState.PermissionNeeded, controller.state.value)
controller.check(true); advanceUntilIdle()
assertEquals(UpdateUiState.PermissionNeeded, controller.state.value)
controller.onResume(); advanceUntilIdle()
assertEquals(UpdateUiState.InstallCancelled, controller.state.value)
assertEquals(0, platform.installs)
platform.settingsAvailable = false
controller.install(); advanceUntilIdle()
assertEquals(UpdateUiState.NoInstaller, controller.state.value)
}
@Test fun permissionDismissalRetainsReadyAndInstallReopensSettings() = runTest {
val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
val metadata = controller.release.value
platform.permission = false
controller.install(); advanceUntilIdle()
assertEquals(UpdateUiState.PermissionNeeded, controller.state.value)
controller.install(); advanceUntilIdle()
assertEquals(2, platform.settingsOpens)
controller.cancel(); advanceUntilIdle()
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
assertEquals(metadata, controller.release.value)
assertArrayEquals(apk, File(directory, "verified.apk").readBytes())
controller.install(); advanceUntilIdle()
assertEquals(UpdateUiState.PermissionNeeded, controller.state.value)
assertEquals(3, platform.settingsOpens)
assertEquals(0, platform.installs)
}
@Test fun handoffDismissalKeepsReadyWithoutAllowingAnotherOwnedSession() = runTest {
val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
platform.outcome = InstallOutcome.Pending
controller.install(); advanceUntilIdle()
controller.cancel(); advanceUntilIdle()
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
controller.handleInstallOutcome(InstallOutcome.Pending); advanceUntilIdle()
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
val before = requests.size
controller.install(); advanceUntilIdle()
controller.check(true); advanceUntilIdle()
controller.download(); advanceUntilIdle()
assertEquals(1, platform.installs)
assertEquals(before, requests.size)
assertArrayEquals(apk, File(directory, "verified.apk").readBytes())
controller.handleInstallOutcome(InstallOutcome.Cancelled); advanceUntilIdle()
controller.install(); advanceUntilIdle()
assertEquals(2, platform.installs)
}
@Test fun dismissalDuringInstallVerificationDoesNotDiscardVerifiedBytes() = runTest {
val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
platform.inspectAction = { controller.cancel() }
controller.install(); advanceUntilIdle()
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
assertArrayEquals(apk, File(directory, "verified.apk").readBytes())
assertEquals(0, platform.installs)
}
@Test fun corruptPersistedBytesAndUnverifiedPartialsAreDiscarded() = runTest {
val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
File(directory, "verified.apk").appendText("corrupt")
File(directory, "download.part").writeText("incomplete")
val recreated = controller(this); advanceUntilIdle()
assertEquals(UpdateUiState.Idle, recreated.state.value)
assertFalse(File(directory, "verified.apk").exists())
assertFalse(File(directory, "download.part").exists())
}
@Test fun downloadHttpFailureRetriesWithoutFalseSuccess() = runTest {
val controller = controller(this); controller.check(true); advanceUntilIdle()
assetStatus = 403
controller.download(); advanceUntilIdle()
assertEquals(UpdateFailure.HttpRefused, (controller.state.value as UpdateUiState.Failed).kind)
assetStatus = 200
controller.retry(); advanceUntilIdle()
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
}
@Test fun failuresIdentifyCheckDownloadAndInstallWithoutParsingMessages() = runTest {
val controller = controller(this)
releaseStatus = 403
controller.check(true); advanceUntilIdle()
assertEquals(UpdateOperation.Check, (controller.state.value as UpdateUiState.Failed).operation)
releaseStatus = 200
controller.retry(); advanceUntilIdle()
assetStatus = 403
controller.download(); advanceUntilIdle()
assertEquals(UpdateOperation.Download, (controller.state.value as UpdateUiState.Failed).operation)
assetStatus = 200
controller.retry(); advanceUntilIdle()
File(directory, "verified.apk").writeBytes(ByteArray(apk.size))
controller.install(); advanceUntilIdle()
val failed = controller.state.value as UpdateUiState.Failed
assertEquals(UpdateOperation.Install, failed.operation)
assertEquals(UpdateFailure.ChecksumMismatch, failed.kind)
assertFalse(File(directory, "ready.json").exists())
}
@Test fun interruptedWireDownloadDoesNotPersistReady() = runTest {
truncated = true; wireTruncated = true
val controller = controller(this); controller.check(true); advanceUntilIdle()
controller.download(); advanceUntilIdle()
assertTrue(controller.state.value is UpdateUiState.Failed)
assertFalse(File(directory, "ready.json").exists())
assertFalse(File(directory, "download.part").exists())
}
@Test fun exhaustedStorageFailsBeforeDownloading() = runTest {
val fullDirectory = object : File(directory.absolutePath) { override fun getUsableSpace(): Long = 0 }
val controller = HouseholdUpdateController(this, preferences, platform, UpdateTransport(true),
VerifiedUpdateStore(fullDirectory, platform), listOf("armeabi-v7a"), 30)
controller.check(true); advanceUntilIdle()
val before = requests.size
controller.download(); advanceUntilIdle()
assertEquals(UpdateFailure.InsufficientStorage, (controller.state.value as UpdateUiState.Failed).kind)
assertEquals(before, requests.size)
}
@Test fun successCallbackRefreshesPackageManagerInsteadOfClaimingAnUpgrade() = runTest {
val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
platform.outcome = InstallOutcome.Success
controller.install(); advanceUntilIdle()
assertEquals(65L, controller.installedVersion.value.versionCode)
assertTrue(File(directory, "verified.apk").exists())
controller.cancel(); advanceUntilIdle(); controller.install(); advanceUntilIdle()
assertEquals(1, platform.installs)
platform.installed = platform.archive
controller.onResume(); advanceUntilIdle()
assertEquals(100001L, controller.installedVersion.value.versionCode)
assertEquals(UpdateUiState.Idle, controller.state.value)
assertFalse(File(directory, "verified.apk").exists())
}
@Test fun successCallbackDeliveredToTheReplacedProcessDoesNotLeaveAFalseHandoff() = runTest {
val old = controller(this); old.check(true); advanceUntilIdle(); old.download(); advanceUntilIdle()
platform.installed = platform.archive
val replaced = controller(this); advanceUntilIdle()
replaced.handleInstallOutcome(InstallOutcome.Success); advanceUntilIdle()
assertEquals(100001L, replaced.installedVersion.value.versionCode)
assertEquals(UpdateUiState.Idle, replaced.state.value)
replaced.handleInstallOutcome(InstallOutcome.Cancelled); advanceUntilIdle()
assertEquals(UpdateUiState.Idle, replaced.state.value)
assertFalse(File(directory, "verified.apk").exists())
replaced.check(true); advanceUntilIdle()
assertEquals(UpdateUiState.UpToDate, replaced.state.value)
}
@Test fun reentryCheckCannotReplacePendingConfirmationOrStartAnotherSession() = runTest {
val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
platform.outcome = InstallOutcome.Pending
controller.install(); advanceUntilIdle()
val before = requests.size
controller.check(true); advanceUntilIdle()
assertEquals(UpdateUiState.HandingOff, controller.state.value)
controller.install(); advanceUntilIdle()
assertEquals(1, platform.installs)
assertEquals(before, requests.size)
controller.handleInstallOutcome(InstallOutcome.Cancelled); advanceUntilIdle()
assertEquals(UpdateUiState.InstallCancelled, controller.state.value)
assertTrue(File(directory, "verified.apk").exists())
}
@Test fun productionTransportRefusesCleartextBeforeRequesting() = runTest {
try {
UpdateTransport().latest("$base/releases/latest", listOf("armeabi-v7a"), 30)
fail("Cleartext should be refused")
} catch (error: UpdateException) { assertEquals(UpdateFailure.InvalidMetadata, error.kind) }
assertTrue(requests.isEmpty())
}
@Test fun unavailableNetworkIsOfflineNotUpToDate() = runTest {
server.stop(0)
val controller = controller(this); controller.check(true); advanceUntilIdle()
assertEquals(UpdateUiState.Offline, controller.state.value)
}
@Test fun interruptedFirstCheckIsNetworkFailureNotMissingDownloadStorage() = runTest {
directory = File(directory, "not-created")
metadataTruncated = true
val controller = controller(this); controller.check(true); advanceUntilIdle()
assertEquals(UpdateFailure.Network, (controller.state.value as UpdateUiState.Failed).kind)
assertFalse(directory.exists())
}
@Test fun compatibleArm32IsPreferredAndUniversalRespectsMinimumSdk() {
val arm32 = manifest().assets.single()
val arm64 = arm32.copy(abi = "arm64-v8a", name = "Wolfskin-arm64-v8a.apk")
val universal = arm32.copy(abi = "universal", name = "Wolfskin.apk", minSdk = 31)
val manifest = manifest().copy(assets = listOf(arm64, arm32, universal))
assertEquals(arm32, manifest.selectAsset(listOf("arm64-v8a", "armeabi-v7a"), 30))
try { manifest.selectAsset(listOf("unknown"), 30); fail("Minimum SDK must be enforced") }
catch (error: UpdateException) { assertEquals(UpdateFailure.UnsupportedAbi, error.kind) }
}
}

View file

@ -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<Int>()
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.")

View file

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

View file

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

View file

@ -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<ComponentActivity>()
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<Key>) {
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)
}
}

View file

@ -16,6 +16,7 @@ import androidx.compose.ui.input.key.type
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.justinvisser.wolfskin.design.DiscoverOverlay import org.justinvisser.wolfskin.design.DiscoverOverlay
import org.justinvisser.wolfskin.design.ShellKey import org.justinvisser.wolfskin.design.ShellKey
import org.justinvisser.wolfskin.design.isScreenEntryActive
import org.justinvisser.wolfskin.design.readShellKey import org.justinvisser.wolfskin.design.readShellKey
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@ -90,7 +91,7 @@ class WolfskinTitleViewModel @Inject constructor(
fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences, fixtures: Boolean = BuildConfig.DEBUG, force: Boolean = false) { fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences, fixtures: Boolean = BuildConfig.DEBUG, force: Boolean = false) {
if (!force && route == destination && ::viewer.isInitialized && viewer.key == selected.key) return if (!force && route == destination && ::viewer.isInitialized && viewer.key == selected.key) return
if (force && route == destination && ::viewer.isInitialized && viewer.key == selected.key && if (force && route == destination && ::viewer.isInitialized && viewer.key == selected.key &&
title?.data?.status == TitleReadStatus.Ready && title?.data?.item?.held == true && title?.data?.status == TitleReadStatus.Ready &&
(title?.data?.connection != TitleConnection.SignedIn || conductarr.identity.value?.signedIn == true) && (title?.data?.connection != TitleConnection.SignedIn || conductarr.identity.value?.signedIn == true) &&
!destination.startsWith("person/") && fixture == null) { !destination.startsWith("person/") && fixture == null) {
loadJob?.cancel() loadJob?.cancel()
@ -339,7 +340,7 @@ fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferen
if (title?.data?.status == TitleReadStatus.Ready) shell.navFocused = false if (title?.data?.status == TitleReadStatus.Ready) shell.navFocused = false
} }
val person = viewModel.person.takeIf { title != null } val person = viewModel.person.takeIf { title != null }
BackHandler(enabled = title != null) { BackHandler(enabled = isScreenEntryActive() && title != null) {
if (requests?.overlay != null) requests.key(ShellKey.Back) else title?.back(shell) if (requests?.overlay != null) requests.key(ShellKey.Back) else title?.back(shell)
} }
Box(modifier.onPreviewKeyEvent { event -> Box(modifier.onPreviewKeyEvent { event ->

View file

@ -17,7 +17,8 @@ class TitleTrailer(private val context: Context) {
?: source.pathSegments.takeIf { it.firstOrNull() in setOf("shorts", "embed") }?.getOrNull(1) ?: source.pathSegments.takeIf { it.firstOrNull() in setOf("shorts", "embed") }?.getOrNull(1)
else -> null else -> null
} ?: return false } ?: return false
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube:$video")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) // Authority form: SmartTube's IntentExtractor reads the id from Uri.getHost(), which is null for the opaque `vnd.youtube:ID`.
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube://$video")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val handler = context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) val handler = context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY)
.firstOrNull { it.activityInfo.exported } ?: return false .firstOrNull { it.activityInfo.exported } ?: return false
intent.setPackage(handler.activityInfo.packageName) intent.setPackage(handler.activityInfo.packageName)

View file

@ -1,7 +1,6 @@
package org.justinvisser.wolfskin.tv package org.justinvisser.wolfskin.tv
import android.content.Context import android.content.Context
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -80,7 +79,6 @@ class WolfskinShellViewModel @Inject constructor(
val seeAll: WolfskinSeeAllLists, val seeAll: WolfskinSeeAllLists,
val conductarr: ConductarrSession, val conductarr: ConductarrSession,
artwork: WolfskinArtwork, artwork: WolfskinArtwork,
val updates: org.justinvisser.wolfskin.tv.update.UpdateController,
) : ViewModel() { ) : ViewModel() {
init { artwork.configure(context) } init { artwork.configure(context) }
val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow
@ -196,12 +194,13 @@ class WolfskinShellViewModel @Inject constructor(
is ShellAction.Destination -> { is ShellAction.Destination -> {
val target = if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route) val target = if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route)
when { 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 action.preservePushed && navigation.backStack.size > 1 -> navigation.backStack[0] = target
else -> navigation.replace(target) else -> navigation.replace(target)
} }
} }
ShellAction.Pop -> navigation.goBack() ShellAction.Pop -> navigation.goBack()
ShellAction.Exit -> navigation.requestExit()
is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group)) is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group))
is ShellAction.Person -> selectPerson(action.person, server) is ShellAction.Person -> selectPerson(action.person, server)
ShellAction.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) } ShellAction.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) }
@ -226,7 +225,11 @@ class WolfskinShellViewModel @Inject constructor(
val saved = withContext(Dispatchers.IO) { val saved = withContext(Dispatchers.IO) {
repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) } repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) }
} }
if (saved == null || saved.isProtected) return@launch if (saved == null || saved.isProtected) {
if (ConductarrPreference.fixtures) return@launch
setupNavigation.navigateTo(SetupDestination.UserList(server))
return@launch
}
try { try {
val current = repository.changeUser(server, saved) val current = repository.changeUser(server, saved)
coroutineContext.ensureActive() coroutineContext.ensureActive()
@ -234,7 +237,7 @@ class WolfskinShellViewModel @Inject constructor(
} catch (exception: Exception) { } catch (exception: Exception) {
if (exception is kotlinx.coroutines.CancellationException) throw exception if (exception is kotlinx.coroutines.CancellationException) throw exception
coroutineContext.ensureActive() coroutineContext.ensureActive()
session.state?.apply { peopleStatus = PeopleStatus.Unavailable; pickerVisible = true } setupNavigation.navigateTo(SetupDestination.UserList(server))
} }
} }
} }
@ -263,7 +266,6 @@ fun WolfskinApp(
{ shell, homeServer, homeUser, contentModifier -> HomeDestination(shell, homeServer, homeUser, contentModifier) }, { shell, homeServer, homeUser, contentModifier -> HomeDestination(shell, homeServer, homeUser, contentModifier) },
) { ) {
val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle() 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) } 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) } androidx.lifecycle.compose.LifecycleEventEffect(androidx.lifecycle.Lifecycle.Event.ON_RESUME) { viewModel.onForeground(server, user) }
LaunchedEffect(identity?.signedIn, identity?.person) { LaunchedEffect(identity?.signedIn, identity?.person) {
@ -273,7 +275,9 @@ fun WolfskinApp(
} }
} }
val loading = remember(user.id) { val loading = remember(user.id) {
ShellState(listOf(ShellPerson(user.name.orEmpty().lowercase(), user.name.orEmpty(), user.name.orEmpty().take(1), false)), user.name.orEmpty().lowercase()).apply { peopleStatus = PeopleStatus.Loading } ShellState(listOf(ShellPerson(user.name.orEmpty().lowercase(), user.name.orEmpty(), user.name.orEmpty().take(1), false)), user.name.orEmpty().lowercase()) {
if (it == ShellAction.Exit) navigationManager.requestExit()
}.apply { peopleStatus = PeopleStatus.Loading }
} }
val state = viewModel.session.state ?: loading val state = viewModel.session.state ?: loading
val discover = rememberDiscoverState( val discover = rememberDiscoverState(
@ -287,6 +291,7 @@ fun WolfskinApp(
}, },
) )
RequestsRefresh(state, viewModel::refreshRequests) RequestsRefresh(state, viewModel::refreshRequests)
val onBack = rememberWolfskinBackHandler(state, navigationManager)
WolfskinTheme { WolfskinTheme {
CompositionLocalProvider(LocalShellState provides state, LocalDiscoverState provides discover, CompositionLocalProvider(LocalShellState provides state, LocalDiscoverState provides discover,
org.justinvisser.wolfskin.design.LocalScreenRatings provides if (identity?.signedIn == true && !ConductarrPreference.fixtures) remember(identity?.client) { ConductarrRatings(identity!!.client) } else null) { org.justinvisser.wolfskin.design.LocalScreenRatings provides if (identity?.signedIn == true && !ConductarrPreference.fixtures) remember(identity?.client) { ConductarrRatings(identity!!.client) } else null) {
@ -302,16 +307,10 @@ fun WolfskinApp(
} }
NavDisplay( NavDisplay(
backStack = navigationManager.backStack, backStack = navigationManager.backStack,
onBack = { if (navigationManager.backStack.last().fullScreen) navigationManager.goBack() else state.key(org.justinvisser.wolfskin.design.ShellKey.Back) }, onBack = onBack,
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()), entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
entryProvider = { destination -> entryProvider = { destination ->
NavEntry( NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") {
destination,
contentKey = "${destination}_${server.id}_${user.id}",
metadata = if (destination is Destination.Wolfskin && destination.route == "updates") {
org.justinvisser.wolfskin.tv.update.updatesRouteTransitions
} else emptyMap(),
) {
// Cached entries must not retain the launch shell after sign-in completes. // Cached entries must not retain the launch shell after sign-in completes.
val state = LocalShellState.current val state = LocalShellState.current
val discover = LocalDiscoverState.current val discover = LocalDiscoverState.current
@ -325,10 +324,10 @@ fun WolfskinApp(
LaunchedEffect(destination, state, isCurrent) { LaunchedEffect(destination, state, isCurrent) {
if (isCurrent && !destination.fullScreen) state.enterDestination(route) if (isCurrent && !destination.fullScreen) state.enterDestination(route)
} }
org.justinvisser.wolfskin.design.ScreenEntry(state, route, isCurrent) {
if (destination.fullScreen) { if (destination.fullScreen) {
DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize()) DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize())
} else { } else {
BackHandler(enabled = isCurrent) { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) }
val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations)
if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) { if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) {
val jellyfinIdentity = destination is Destination.MediaItem || android.net.Uri.decode(route.substringAfter('/')).toUUIDOrNull() != null val jellyfinIdentity = destination is Destination.MediaItem || android.net.Uri.decode(route.substringAfter('/')).toUUIDOrNull() != null
@ -337,15 +336,11 @@ fun WolfskinApp(
} else TitleDestination(route, state, preferences) } else TitleDestination(route, state, preferences)
} else { } else {
ShellSurface(state, handlesContentKeys = upstream == null, ShellSurface(state, handlesContentKeys = upstream == null,
settingsInstalledVersion = installedVersion.versionName,
overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier -> overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier ->
when { 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 || 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) upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
destination is Destination.Home -> homeContent(state, server, user, 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/") -> 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) 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) destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier)
@ -354,6 +349,7 @@ fun WolfskinApp(
} }
} }
} }
}
} }
}, },
) )

View file

@ -0,0 +1,28 @@
package org.justinvisser.wolfskin.tv
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.findActivity
import org.justinvisser.wolfskin.design.ShellKey
import org.justinvisser.wolfskin.design.ShellState
/** Own root Back even when NavDisplay has no parent; child menus/dialogs retain first refusal. */
@Composable
internal fun rememberWolfskinBackHandler(shell: ShellState, navigation: NavigationManager): () -> Unit {
val activity = checkNotNull(LocalContext.current.findActivity()) { "Wolfskin root requires an Activity" }
LaunchedEffect(navigation, activity) {
navigation.exitRequests.collect { activity.finishAndRemoveTask() }
}
val onBack: () -> Unit = remember(shell, navigation) {
{
if (navigation.backStack.lastOrNull()?.fullScreen == true) navigation.goBack()
else shell.key(ShellKey.Back)
}
}
BackHandler(onBack = onBack)
return onBack
}

View file

@ -0,0 +1,22 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import android.content.Context
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
/** Only an expired current login leaves saved content; outages and late failures keep it usable. */
fun recoverWolfskinSession(
context: Context,
exception: Exception,
expected: CurrentUser?,
repository: ServerRepository,
setup: SetupNavigationManager,
) {
if (exception !is InvalidStatusException || exception.status != 401 || expected == null || repository.current.value !== expected) return
WolfskinScreenPreference.initialize(context)
if (WolfskinScreenPreference.enabled) setup.navigateTo(SetupDestination.UserList(expected.server))
}

View file

@ -0,0 +1,187 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.input.KeyboardType
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.Button
import androidx.tv.material3.ButtonDefaults
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.ui.components.EditTextBox
import com.github.damontecres.wholphin.ui.setup.PinEntryDialog
import kotlinx.coroutines.launch
import org.justinvisser.wolfskin.design.ShellAction
import org.justinvisser.wolfskin.design.ShellSurface
import org.justinvisser.wolfskin.design.WolfskinTheme
import org.justinvisser.wolfskin.design.WolfskinTokens
import org.justinvisser.wolfskin.design.Wordmark
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.tv.WolfskinSetupViewModel.Stage
/** Intercept both stock setup destinations before either upstream selector can be composed. */
@Composable
fun WolfskinSetupContent(destination: SetupDestination, modifier: Modifier = Modifier, model: WolfskinSetupViewModel? = null): Boolean {
if (destination != SetupDestination.ServerList && destination !is SetupDestination.UserList) return false
val context = LocalContext.current
remember(context) {
WolfskinScreenPreference.initialize(context)
ConductarrPreference.initialize(context)
true
}
if (!WolfskinScreenPreference.enabled) return false
WolfskinSetupScreen(destination, modifier, model ?: hiltViewModel())
return true
}
/** Saved people remain local; network failures expose retry and real server authentication. */
@Composable
internal fun WolfskinSetupScreen(
destination: SetupDestination,
modifier: Modifier = Modifier,
model: WolfskinSetupViewModel = hiltViewModel(),
) {
val scope = rememberCoroutineScope()
val hint = (destination as? SetupDestination.UserList)?.server
LaunchedEffect(destination) { model.load(hint) }
val retrySetup: () -> Unit = { scope.launch { model.retry() } }
model.onAction = { action ->
when (action) {
is ShellAction.Person -> scope.launch { model.select(action.person) }
is ShellAction.Destination -> model.showSettings()
else -> Unit
}
}
BackHandler {
if (model.pinUser != null) model.dismissPin()
else model.returnToPicker()
}
WolfskinTheme {
val picker = model.picker
if (model.stage == Stage.Picker && picker != null) {
ShellSurface(picker, modifier.testTag("wolfskin-setup-picker"))
} else {
SetupPanel(model.stage, modifier) {
when (model.stage) {
Stage.Loading -> Text("Reading saved profiles…", color = C.muted, style = WolfskinTokens.Type.body)
Stage.Unconfigured -> ServerForm(model)
Stage.Login -> LoginForm(model)
Stage.Settings -> {
SetupButton("Sign in to another profile", !model.busy, initialFocus = true) { model.showLogin(null) }
SetupButton("Back to profiles", !model.busy, onClick = model::returnToPicker)
}
Stage.Unavailable -> {
Text(model.message.orEmpty(), color = C.muted, style = WolfskinTokens.Type.body)
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
SetupButton("Try again", !model.busy, initialFocus = true, onClick = retrySetup)
if (model.server != null) SetupButton("Sign in", !model.busy) { model.showLogin(null) }
}
}
Stage.Picker -> Unit
}
}
}
model.pinUser?.let { user ->
PinEntryDialog(
onDismissRequest = model::dismissPin,
onClickServerAuth = { model.showLogin(user) },
onTextChange = { value -> scope.launch { model.enterPin(value) } },
)
}
}
}
@Composable
private fun SetupPanel(stage: Stage, modifier: Modifier, content: @Composable () -> Unit) {
Box(modifier.fillMaxSize().background(C.bg).testTag("wolfskin-setup-${stage.name.lowercase()}")) {
Wordmark(Modifier.offset(L.safeSide, L.safeTopPicker))
Column(Modifier.align(Alignment.Center).width(S.pickerErrorPanelWidth),
verticalArrangement = Arrangement.spacedBy(S.panelGap)) {
Text(when (stage) {
Stage.Unconfigured -> "Connect your household server"
Stage.Login -> "Sign in to your profile"
Stage.Settings -> "Settings"
else -> "Who’s watching?"
}, color = C.fg, style = WolfskinTokens.Type.screenTitle)
content()
}
}
}
@Composable
private fun ServerForm(model: WolfskinSetupViewModel) {
var url by remember { mutableStateOf("") }
val scope = rememberCoroutineScope()
val focus = remember { FocusRequester() }
LaunchedEffect(Unit) { focus.requestFocus() }
Text("Enter your Jellyfin server URL once. Wolfskin will remember it on this TV.", color = C.muted, style = WolfskinTokens.Type.body)
EditTextBox(value = url, onValueChange = { url = it }, modifier = Modifier.fillMaxWidth().focusRequester(focus).testTag("household-server-url"),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri))
model.message?.let { Text(it, color = C.warn, style = WolfskinTokens.Type.body) }
SetupButton(if (model.busy) "Connecting…" else "Try again", !model.busy && url.isNotBlank()) {
scope.launch { model.connect(url) }
}
}
@Composable
private fun LoginForm(model: WolfskinSetupViewModel) {
var username by remember(model.loginUser) { mutableStateOf(model.loginUser?.name.orEmpty()) }
var password by remember(model.loginUser) { mutableStateOf("") }
val scope = rememberCoroutineScope()
val focus = remember { FocusRequester() }
val initialUsername = remember(model.loginUser) { username.isBlank() }
LaunchedEffect(model.loginUser) { focus.requestFocus() }
Text(model.server?.name ?: model.server?.url.orEmpty(), color = C.muted, style = WolfskinTokens.Type.body)
Text("Username", color = C.fg, style = WolfskinTokens.Type.body)
EditTextBox(value = username, onValueChange = { username = it }, modifier = Modifier.fillMaxWidth()
.then(if (initialUsername) Modifier.focusRequester(focus) else Modifier).testTag("profile-username"))
Text("Password", color = C.fg, style = WolfskinTokens.Type.body)
EditTextBox(value = password, onValueChange = { password = it }, modifier = Modifier.fillMaxWidth()
.then(if (!initialUsername) Modifier.focusRequester(focus) else Modifier).testTag("profile-password"),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password))
model.message?.let { Text(it, color = C.warn, style = WolfskinTokens.Type.body) }
SetupButton(if (model.busy) "Signing in…" else "Sign in", !model.busy && username.isNotBlank()) {
val submitted = password
password = ""
scope.launch { model.login(username, submitted) }
}
}
@Composable
private fun SetupButton(label: String, enabled: Boolean, initialFocus: Boolean = false, onClick: () -> Unit) {
val focus = remember { FocusRequester() }
LaunchedEffect(Unit) { if (initialFocus && enabled) focus.requestFocus() }
Button(onClick = onClick, enabled = enabled, modifier = Modifier.focusRequester(focus),
shape = ButtonDefaults.shape(shape = RoundedCornerShape(WolfskinTokens.Radius.button)),
colors = ButtonDefaults.colors(containerColor = C.buttonSecondary, contentColor = C.fg,
focusedContainerColor = C.fg, focusedContentColor = C.onFocus)) {
Text(label, style = WolfskinTokens.Type.button)
}
}

View file

@ -0,0 +1,208 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import org.justinvisser.wolfskin.design.ShellAction
import org.justinvisser.wolfskin.design.ShellPerson
import org.justinvisser.wolfskin.design.ShellState
/** Setup never clears the remembered household endpoint or login merely to show the picker. */
@HiltViewModel
class WolfskinSetupViewModel @Inject constructor(
private val repository: ServerRepository,
private val navigation: NavigationManager,
private val setup: SetupNavigationManager,
private val session: WolfskinSession,
private val conductarr: ConductarrSession,
) : ViewModel() {
internal enum class Stage { Loading, Unconfigured, Picker, Login, Settings, Unavailable }
internal var stage by mutableStateOf(Stage.Loading)
private set
internal var server by mutableStateOf<JellyfinServer?>(null)
private set
internal var picker by mutableStateOf<ShellState?>(null)
private set
internal var message by mutableStateOf<String?>(null)
private set
internal var busy by mutableStateOf(false)
private set
internal var pinUser by mutableStateOf<JellyfinUser?>(null)
private set
internal var loginUser by mutableStateOf<JellyfinUser?>(null)
private set
private var users: List<JellyfinUser> = emptyList()
internal var onAction: (ShellAction) -> Unit = {}
/** Recover Room's saved household server even if the old selector erased its preference. */
internal suspend fun load(hint: JellyfinServer?) = perform {
conductarr.close()
server = hint ?: repository.current.value?.server ?: when (val recent = repository.getMostRecentServer()) {
is RestoredSession.ServerOnly -> recent.server
is RestoredSession.Success -> recent.currentUser.server
RestoredSession.None -> withContext(Dispatchers.IO) {
repository.serverDao.getServers().maxWithOrNull(compareBy { it.server.lastUsed })?.server
}
}
val household = server
if (household == null) {
stage = Stage.Unconfigured
return@perform
}
users = withContext(Dispatchers.IO) { repository.serverDao.getServer(household.id)?.users.orEmpty() }
if (users.isEmpty()) {
users = withContext(Dispatchers.IO) {
repository.jellyfin.createApi(household.url).userApi.getPublicUsers().content.map {
JellyfinUser(id = it.id, name = it.name, serverId = household.id, accessToken = null)
}
}
}
if (users.isEmpty()) {
showLogin(null)
} else {
val people = users.map { ShellPerson(it.id.toString(), it.name.orEmpty(), it.name.orEmpty().take(1).uppercase(), false, it.id.toString()) }
picker = ShellState(people, people.first().key) { onAction(it) }
stage = Stage.Picker
}
}
/** Retry the startup server/profile recovery, never the failed saved-token request. */
internal suspend fun retry() {
if (busy) return
stage = Stage.Loading
load(null)
}
/** Discovery and persistence use the same Jellyfin contract as upstream's add-server action. */
internal suspend fun connect(url: String) = perform {
val household = withContext(Dispatchers.IO) {
val candidate = repository.jellyfin.discovery.getRecommendedServers(url.trim())
.sortedBy { it.score }.firstOrNull { it.score != RecommendedServerInfoScore.BAD }
val info = candidate?.systemInfo?.getOrNull()
val id = info?.id?.toUUIDOrNull()
require(candidate != null && info != null && id != null && info.startupWizardCompleted == true) { "Could not connect to the household server. Check the URL and try again." }
JellyfinServer(id, info.serverName, candidate.address, info.version).also {
repository.addAndChangeServer(it)
}
}
server = household
setup.navigateTo(SetupDestination.UserList(household))
}
/** Protected saved users must authenticate before changeUser is allowed to run. */
internal suspend fun select(person: ShellPerson) {
picker?.pickerVisible = true
if (busy) return
val selected = users.firstOrNull { it.id.toString() == person.jellyfinUserId } ?: return
when {
selected.accessToken.isNullOrBlank() || selected.requireLogin -> showLogin(selected)
selected.hasPin -> pinUser = selected
else -> changeUser(selected)
}
}
internal fun showLogin(user: JellyfinUser?) {
pinUser = null
loginUser = user
message = null
stage = Stage.Login
}
internal fun dismissPin() { pinUser = null }
internal fun showSettings() {
picker?.pickerVisible = true
if (!busy) stage = Stage.Settings
}
internal fun returnToPicker() {
if (busy || picker == null) return
picker?.pickerVisible = true
stage = Stage.Picker
}
internal suspend fun enterPin(value: String) {
val selected = pinUser ?: return
if (value == selected.pin) {
pinUser = null
changeUser(selected)
}
}
private suspend fun changeUser(user: JellyfinUser) = perform {
try {
complete(repository.changeUser(requireNotNull(server), user))
} catch (exception: InvalidStatusException) {
if (exception.status != 401) throw exception
showLogin(user)
message = "Your saved sign-in expired. Sign in again to continue."
}
}
/** A password is sent only to the saved household server and never retained by this model. */
internal suspend fun login(username: String, password: String) = perform {
val household = requireNotNull(server)
val current = withContext(Dispatchers.IO) {
val result = repository.jellyfin.createApi(household.url).userApi
.authenticateUserByName(username = username, password = password).content
repository.changeUser(household.url, result, loginUser)
}
complete(current)
}
private fun complete(current: com.github.damontecres.wholphin.data.CurrentUser) {
if (ConductarrPreference.fixtures) {
session.state = null
} else {
val saved = users.filterNot { it.id == current.user.id } + current.user
val people = saved.map { ShellPerson(it.name.orEmpty().lowercase(), it.name.orEmpty(), it.name.orEmpty().take(1).uppercase(), false, it.id.toString()) }
session.state = ShellState(people, current.user.name.orEmpty().lowercase()) { session.onAction(it) }.apply {
pickerVisible = false
pickerLaunch = false
}
}
session.serverId = current.server.id.toString()
session.fixtureMode = ConductarrPreference.fixtures
navigation.reloadHome()
setup.navigateTo(SetupDestination.AppContent(current))
}
private suspend fun perform(action: suspend () -> Unit) {
if (busy) return
busy = true
message = null
try {
action()
} catch (exception: Exception) {
if (exception is CancellationException) throw exception
message = if (exception is InvalidStatusException && exception.status == 401) "Invalid username or password. Try again."
else if (exception is InvalidStatusException && exception.status == 403) "This profile is not authorized. Choose another profile or sign in again."
else if (server == null && stage == Stage.Unconfigured) "Could not connect to the household server. Check the URL and try again."
else if (server == null) "Could not read the saved household server. Try again."
else "The household server didn’t answer. Try again or sign in with your server credentials."
if (stage != Stage.Login && stage != Stage.Unconfigured) stage = Stage.Unavailable
} finally {
busy = false
}
}
}

View file

@ -47,6 +47,7 @@ class HomeLibraryCache @Inject constructor(@param:ApplicationContext context: Co
put("seriesTitle", title.seriesTitle); put("season", title.season); put("episode", title.episode) put("seriesTitle", title.seriesTitle); put("season", title.season); put("episode", title.episode)
put("resume", title.resumeSeconds); put("played", title.played); put("addedAt", title.addedAt) put("resume", title.resumeSeconds); put("played", title.played); put("addedAt", title.addedAt)
put("genres", JSONArray(title.genres)); put("tmdb", title.tmdbId) put("genres", JSONArray(title.genres)); put("tmdb", title.tmdbId)
put("seriesId", title.seriesId); put("lastPlayedAt", title.lastPlayedAt)
}) } }) }
} }
@ -59,6 +60,7 @@ class HomeLibraryCache @Inject constructor(@param:ApplicationContext context: Co
item.getString("id"), item.getString("title"), item.getString("type"), number("year"), number("runtime"), number("seasons"), item.getString("overview"), item.getString("id"), item.getString("title"), item.getString("type"), number("year"), number("runtime"), number("seasons"), item.getString("overview"),
string("poster"), string("backdrop"), item.getBoolean("posterOnly"), string("seriesTitle"), number("season"), number("episode"), item.getDouble("resume"), item.getBoolean("played"), item.getString("addedAt"), string("poster"), string("backdrop"), item.getBoolean("posterOnly"), string("seriesTitle"), number("season"), number("episode"), item.getDouble("resume"), item.getBoolean("played"), item.getString("addedAt"),
List(genres.length()) { genres.getString(it) }, held = true, tmdbId = number("tmdb"), List(genres.length()) { genres.getString(it) }, held = true, tmdbId = number("tmdb"),
seriesId = string("seriesId"), lastPlayedAt = if (item.isNull("lastPlayedAt")) null else item.getLong("lastPlayedAt"),
) )
} }

View file

@ -3,7 +3,12 @@ package org.justinvisser.wolfskin.tv.read
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.ImageUrlService
import java.util.UUID import java.util.UUID
import java.time.ZoneOffset
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi
@ -27,18 +32,39 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val
suspend fun readWatch(userId: UUID): HomeLibrary { suspend fun readWatch(userId: UUID): HomeLibrary {
val resumeItems = api.itemsApi.getResumeItems(userId = userId, fields = FIELDS, mediaTypes = listOf(MediaType.VIDEO), limit = 20).content.items val resumeItems = api.itemsApi.getResumeItems(userId = userId, fields = FIELDS, mediaTypes = listOf(MediaType.VIDEO), limit = 20).content.items
val nextItems = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items val nextItems = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items
val series = (resumeItems + nextItems).mapNotNull { it.seriesId }.distinct().associateWith { id -> val seriesIds = buildSet {
try { api.userLibraryApi.getItem(userId = userId, itemId = id).content.providerIds?.get("Tmdb")?.toIntOrNull() } resumeItems.forEach { it.seriesId?.let(::add) }
catch (exception: Exception) { nextItems.forEach { it.seriesId?.let(::add) }
if (exception is kotlinx.coroutines.CancellationException) throw exception }
null val series = coroutineScope {
} val limiter = Semaphore(WATCH_READ_CONCURRENCY)
seriesIds.associateWith { id -> async { limiter.withPermit { readSeriesWatch(userId, id) } } }
.mapValues { it.value.await() }
} }
val resume = resumeItems.map { title(it, series) } val resume = resumeItems.map { title(it, series) }
val next = nextItems.map { title(it, series) } val next = nextItems.map { title(it, series) }
return HomeLibrary(resume = resume, nextUp = next) return HomeLibrary(resume = resume, nextUp = next)
} }
private suspend fun readSeriesWatch(userId: UUID, id: UUID): SeriesWatch {
val tmdb = try { api.userLibraryApi.getItem(userId = userId, itemId = id).content.providerIds?.get("Tmdb")?.toIntOrNull() }
catch (exception: Exception) {
if (exception is kotlinx.coroutines.CancellationException) throw exception
null
}
// Next-up episodes are unwatched; date the series by its latest actually watched episode.
val playedAt = try {
api.itemsApi.getItems(userId = userId, parentId = id, recursive = true,
includeItemTypes = listOf(BaseItemKind.EPISODE), isPlayed = true, enableUserData = true,
sortBy = listOf(ItemSortBy.DATE_PLAYED), sortOrder = listOf(SortOrder.DESCENDING), limit = 1)
.content.items.firstOrNull()?.userData?.lastPlayedDate?.toInstant(ZoneOffset.UTC)?.toEpochMilli()
} catch (exception: Exception) {
if (exception is kotlinx.coroutines.CancellationException) throw exception
null
}
return SeriesWatch(tmdb, playedAt)
}
/** Library failures do not discard an already successful watch read. */ /** Library failures do not discard an already successful watch read. */
suspend fun readLibrary(userId: UUID): HomeLibrary { suspend fun readLibrary(userId: UUID): HomeLibrary {
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items val libraries = api.userViewsApi.getUserViews(userId = userId).content.items
@ -66,7 +92,7 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val
return readPlayback(api, userId, id) return readPlayback(api, userId, id)
} }
private fun title(item: BaseItemDto, series: Map<UUID, Int?> = emptyMap()): HomeTitle { private fun title(item: BaseItemDto, series: Map<UUID, SeriesWatch> = emptyMap()): HomeTitle {
val wrapped = BaseItem(item) val wrapped = BaseItem(item)
return HomeTitle( return HomeTitle(
id = item.id.toString(), title = item.name.orEmpty(), type = when (item.type) { BaseItemKind.EPISODE -> "episode"; BaseItemKind.SERIES -> "tv"; else -> "movie" }, id = item.id.toString(), title = item.name.orEmpty(), type = when (item.type) { BaseItemKind.EPISODE -> "episode"; BaseItemKind.SERIES -> "tv"; else -> "movie" },
@ -76,12 +102,18 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val
seriesTitle = item.seriesName, season = item.parentIndexNumber, episode = item.indexNumber, seriesTitle = item.seriesName, season = item.parentIndexNumber, episode = item.indexNumber,
resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / TICKS_PER_SECOND.toDouble(), played = item.userData?.played == true, resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / TICKS_PER_SECOND.toDouble(), played = item.userData?.played == true,
addedAt = item.dateCreated?.toString().orEmpty(), genres = item.genres.orEmpty(), held = true, addedAt = item.dateCreated?.toString().orEmpty(), genres = item.genres.orEmpty(), held = true,
tmdbId = if (item.type == BaseItemKind.EPISODE) series[item.seriesId] else item.providerIds?.get("Tmdb")?.toIntOrNull(), tmdbId = if (item.type == BaseItemKind.EPISODE) series[item.seriesId]?.tmdbId else item.providerIds?.get("Tmdb")?.toIntOrNull(),
seriesId = item.seriesId?.toString(),
lastPlayedAt = maxOf(item.userData?.lastPlayedDate?.toInstant(ZoneOffset.UTC)?.toEpochMilli() ?: Long.MIN_VALUE,
series[item.seriesId]?.lastPlayedAt ?: Long.MIN_VALUE).takeUnless { it == Long.MIN_VALUE },
) )
} }
private data class SeriesWatch(val tmdbId: Int?, val lastPlayedAt: Long?)
companion object { companion object {
private const val TICKS_PER_SECOND = 10_000_000L private const val TICKS_PER_SECOND = 10_000_000L
private const val WATCH_READ_CONCURRENCY = 3
private val FIELDS = listOf(ItemFields.PROVIDER_IDS, ItemFields.OVERVIEW, ItemFields.DATE_CREATED, ItemFields.GENRES, ItemFields.CHILD_COUNT) private val FIELDS = listOf(ItemFields.PROVIDER_IDS, ItemFields.OVERVIEW, ItemFields.DATE_CREATED, ItemFields.GENRES, ItemFields.CHILD_COUNT)
} }
} }

View file

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

View file

@ -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<AppPreferences>,
val platform: AndroidUpdatePlatform,
) {
private val preferences = object : UpdatePreferences {
override val autoCheck = dataStore.data.map { it.autoCheckForUpdates }.stateIn(scope, SharingStarted.Eagerly, true)
override suspend fun enabled() = dataStore.data.first().autoCheckForUpdates
override suspend fun url(): String {
val prefs = dataStore.updateData { current ->
val url = UpdateChannel.migrateUrl(context.packageName, current.updateUrl)
if (url == current.updateUrl) current else current.update { updateUrl = url }
}
return prefs.updateUrl
}
override suspend fun setAutoCheck(enabled: Boolean) {
dataStore.updateData { it.update { autoCheckForUpdates = enabled } }
}
}
val controller = HouseholdUpdateController(scope, preferences, platform, UpdateTransport(),
VerifiedUpdateStore(File(context.noBackupFilesDir, "updates"), platform), Build.SUPPORTED_ABIS.toList(), Build.VERSION.SDK_INT)
suspend fun channelUrl(): String = preferences.url()
}
/** Single process-scoped controller binding for every update entry point. */
@Module
@InstallIn(SingletonComponent::class)
object UpdateModule {
@Provides
@Singleton
fun provideUpdateController(service: AndroidUpdateService): UpdateController = service.controller
}

View file

@ -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<String?>(null) }
LaunchedEffect(controller) { controller.check(true) }
DisposableEffect(lifecycle, controller) {
val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) controller.onResume() }
lifecycle.addObserver(observer)
onDispose { lifecycle.removeObserver(observer) }
}
Column(modifier, verticalArrangement = Arrangement.spacedBy(WolfskinTokens.Shell.settingsRowGap)) {
Text("Installed: ${installed.versionName}")
when (val current = state) {
is UpdateUiState.Available -> {
Text("${current.versionName} · ${current.bytes} bytes · ${current.dateText}")
Button(onClick = controller::download) { Text("Download") }
if (current.notes.isNotBlank()) {
Button(onClick = { notes = current.notes }) { Text("Release notes") }
}
}
is UpdateUiState.Downloading -> {
Text("${current.bytesDone} / ${current.bytesTotal ?: "—"} bytes")
Button(onClick = controller::cancel) { Text("Cancel download") }
}
is UpdateUiState.Ready, UpdateUiState.InstallCancelled, UpdateUiState.PermissionNeeded, UpdateUiState.NoInstaller -> {
Text(when (current) {
UpdateUiState.InstallCancelled -> "Installation cancelled; verified download retained"
UpdateUiState.PermissionNeeded -> "Allow updates from this app in Android settings"
UpdateUiState.NoInstaller -> "Android installer or permission settings unavailable"
else -> "Verified update ready"
})
Button(onClick = controller::install) { Text("Install update") }
}
is UpdateUiState.Failed -> {
Text(current.message)
Button(onClick = controller::retry) { Text("Retry") }
}
UpdateUiState.HandingOff -> Text("Confirm installation in Android")
else -> {
Text(when (current) {
UpdateUiState.UpToDate -> "Up to date"
UpdateUiState.NoReleasePublished -> "No release published"
UpdateUiState.Offline -> "No network"
UpdateUiState.Checking -> "Checking"
else -> "Check for an update"
})
Button(onClick = { controller.check(true) }) { Text("Check again") }
}
}
}
notes?.let { body ->
ScrollableDialog(onDismissRequest = { notes = null }) {
item { Markdown(content = body) }
}
}
}

View file

@ -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<String>()
fun show(state: UpdateUiState) {
if (state is UpdateUiState.Available) mutableRelease.value = state
mutableState.value = state
}
fun showInstalled(version: InstalledVersion) { mutableVersion.value = version }
override fun check(manual: Boolean) { actions += "check:$manual" }
override fun download() { actions += "download" }
override fun cancel() { actions += "cancel" }
override fun install() { actions += "install" }
override fun retry() { actions += "retry" }
override fun onResume() { actions += "resume" }
override fun setAutoCheck(enabled: Boolean) { mutableAutoCheck.value = enabled }
}

View file

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

View file

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

View file

@ -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<UpdateUiState>
val autoCheck: StateFlow<Boolean>
val installedVersion: StateFlow<InstalledVersion>
/** Candidate facts survive download/installer transitions and verified-file restoration. */
val release: StateFlow<UpdateUiState.Available?>
fun check(manual: Boolean)
fun download()
/** Discard partial bytes; retain verified bytes and restore Ready without duplicating a system session. */
fun cancel()
fun install()
fun retry()
fun setAutoCheck(enabled: Boolean)
/** Refresh installed identity and unknown-source permission after Android returns. */
fun onResume()
}
/** PackageManager identity, refreshed after returning from the system installer. */
data class InstalledVersion(val versionName: String, val versionCode: Long)
/** Only verified, app-private bytes may produce Ready. Checking never initiates download. */
sealed interface UpdateUiState {
data object Idle : UpdateUiState
data object Checking : UpdateUiState
data object UpToDate : UpdateUiState
data object NoReleasePublished : UpdateUiState
data class Available(val versionName: String, val bytes: Long, val dateText: String, val notes: String) : UpdateUiState
data class Downloading(val bytesDone: Long, val bytesTotal: Long?) : UpdateUiState
data class Ready(val versionName: String) : UpdateUiState
data object PermissionNeeded : UpdateUiState
data object HandingOff : UpdateUiState
data object InstallCancelled : UpdateUiState
data class Failed(val kind: UpdateFailure, val message: String, val operation: UpdateOperation) : UpdateUiState
data object Offline : UpdateUiState
data object NoInstaller : UpdateUiState
}
/** The failed action is structured; UI must not infer it from a reason string. */
enum class UpdateOperation { Check, Download, Install }
/** Failures are distinct from a successful version comparison or an empty channel. */
enum class UpdateFailure {
Network, HttpRefused, InvalidMetadata, UnsupportedAbi, InsufficientStorage,
ChecksumMismatch, WrongPackage, WrongSigner, LowerVersionCode,
}

View file

@ -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<UpdateAction>()
private var previousState: Class<*>? = null
/** Only a changed operation may retire an action; progress cannot steal focus. */
fun update(state: UpdateUiState, value: List<UpdateAction>) {
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
}
}

View file

@ -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>(Intent.EXTRA_INTENT)
if (confirmation != null && service.platform.launchConfirmation(confirmation)) InstallOutcome.Pending else InstallOutcome.Unavailable
}
PackageInstaller.STATUS_SUCCESS -> InstallOutcome.Success
PackageInstaller.STATUS_FAILURE_ABORTED -> InstallOutcome.Cancelled
PackageInstaller.STATUS_FAILURE_STORAGE -> InstallOutcome.InsufficientStorage
else -> InstallOutcome.Failed
}
service.controller.handleInstallOutcome(outcome)
}
}
/** System confirmation is launched for result so cancelling retains the already-verified APK. */
@AndroidEntryPoint
class UpdateConfirmationActivity : ComponentActivity() {
@Inject lateinit var service: AndroidUpdateService
@Suppress("DEPRECATION")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState != null) return
val confirmation = intent.getParcelableExtra<Intent>("confirmation")
if (confirmation == null || confirmation.resolveActivity(packageManager) == null) {
service.controller.handleInstallOutcome(InstallOutcome.Unavailable)
finish()
return
}
try { startActivityForResult(confirmation, 1) } catch (_: RuntimeException) {
service.controller.handleInstallOutcome(InstallOutcome.Unavailable)
finish()
}
}
@Deprecated("System installer uses the activity result contract")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == 1 && resultCode == RESULT_CANCELED) service.controller.handleInstallOutcome(InstallOutcome.Cancelled)
service.controller.onResume()
finish()
}
}

View file

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

View file

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

View file

@ -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<String> = emptyList(),
val actions: List<UpdateAction>,
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"
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -4,7 +4,15 @@
## Who’s watching ## Who’s watching
The Settings gear is horizontally centred beneath the complete people row, independent of person count or selection. Justin confirmed this placement. It uses the same 4 px accent straddle ring as the people, with the existing 1.08 scale and focus-only Settings caption. Down still reaches it only from the final person; Up returns to that person. The Settings gear is horizontally centred beneath the complete people row, independent of person count or selection. Justin confirmed this placement. It uses the same 4 px accent straddle ring as the people, with the existing 1.08 scale and focus-only Settings caption.
Down from any avatar reaches the gear; Up returns to its origin, including after visiting Settings. Left/Right avatar traversal is unchanged.
Source — Justin (2026-11-07): “the settings icon moved, but pressing down only works from the last profile icon. I want it to always move there when pressing down in that screen”.
Root content Back moves to the nav; the next Back **finishes and removes Wolfskin’s Android task**, returning to the launcher without visiting an old Home/setup interface. This applies to Home, Discover, Movies, Shows and Anime. Cold-launch picker Back exits; a reopened picker first dismisses to its opener’s nav. Pushed title/person/settings/Updates pages pop their parent stack, and menus/sheets/unsaved-settings prompts get first refusal. A singleton full-screen page exits rather than becoming a dead end.
Source — Justin (2026-11-07): “pressing back enough times should close wolfskin, currently there is no way to go back to projectivity apart from weird methods via the old ui”.
## Focus visibility ## Focus visibility
@ -14,6 +22,23 @@ The focused card and its ring remain fully inside the viewport, with at least th
Library and Person grids retain six columns and the existing Level 2 row spacing. The final Person column keeps the full poster width; horizontal padding must not shrink it. Library and Person grids retain six columns and the existing Level 2 row spacing. The final Person column keeps the full poster width; horizontal padding must not shrink it.
## Home continuation and navigation — round 2 amendment
Justin asked for one **Continue watching** lane merging in-progress films/episodes and next-up episodes. **Implementation choice:** reuse the existing Next up landscape-tile geometry for the merged lane, rather than the old Resume/wide geometry. Order by most recent actual watch activity and show a series once: its resumable episode takes precedence over its next-up episode. Unknown activity dates retain source order after known dates. The non-rotating hero keeps its resume → next-up → newest → pick precedence and is not duplicated in the lane.
The lane retains the `cw` identity. Legacy local, server, web and plugin Home settings merge saved Resume/Next up rows at the first watch row's position and preserve that row's view options. Existing serialized types remain readable; new settings offer only Continue watching.
Every navigation entry owns its own viewport even while an outgoing page and incoming page coexist during push/Back. Only the active entry may acquire Android focus or register the shell's logical key controller. An outgoing title/person page must never rebind to Home's `LazyListState` when the shared route changes. Down skips non-focusable/pending rows, reaches the final real row, and scrolls it into view after Back and refresh.
An unvisited Home row starts at its leftmost card rather than inheriting horizontal alignment from the previous row. Visited rows retain their remembered column and first-visible position across row changes, title Back and root-tab leave/return. Home does not adopt Discover’s tab-leave memory reset; Library keeps standard grid navigation. Outgoing title/person entries cannot intercept Android Back during their exit animation.
Landscape-card bottom gradients extend through the reserved state-line area, ending at the solid bottom backing with no exposed artwork band, as Justin requested. **Implementation choice for the amount:** title-gradient top padding is **36 px / 18 dp**, down from 54 px / 27 dp. The existing approved 92% bottom opacity is unchanged. Captions and state lines retain their existing geometry.
Home hero actions retain the approved primary + secondary Details layout: [st-007, Mindel’s Resume hero](https://pages.justinvisser.org/wolfskin/tv-spec/img/st-007-mindel-resume-in-the-hero.jpg) and [st-006, Justin’s Play-next hero](https://pages.justinvisser.org/wolfskin/tv-spec/img/st-006-justin-no-resume-hero-falls-back-to-next-up.jpg). Cached Home can paint before fresh watch data verifies playback. During that interval, measure and reserve the real primary button’s footprint without placing, drawing or enabling it; keep Details in its final secondary position and available for browsing. Playback readiness reveals the primary with the existing 200 ms late-line fade, never a lateral layout shift. The copy layer and button/spacing tokens do not change.
Source — Justin (2026-11-07): “the resume button for the home hero item only appears after a second or two, it appearing causes the details button to shift places, this needs to be fixed as well”.
## Episode artwork and dimensions ## Episode artwork and dimensions
Episode cards use **400×280 px (200×140 dp)** art: 55 px / 24.4% taller than the previous 400×225 px tile. Other tile and wide-card sizes are unchanged. The episode shelf is `84 + 280 + 68 = 432 px`; its heading band and 68 px clearance before Cast remain Level 2. Cast moves down by the additional card height rather than overlapping it. Episode cards use **400×280 px (200×140 dp)** art: 55 px / 24.4% taller than the previous 400×225 px tile. Other tile and wide-card sizes are unchanged. The episode shelf is `84 + 280 + 68 = 432 px`; its heading band and 68 px clearance before Cast remain Level 2. Cast moves down by the additional card height rather than overlapping it.
@ -54,6 +79,8 @@ Read-only source measurement: Desperate Housewives has a 2732×1536 Backdrop. Wi
Every Discover lane, including Movies for you, Series for you, service entries, service-specific lanes and Hidden picks, uses a **104 px heading band**: heading line plus **60 px heading-to-card gap**, up from 40 px. This is a consistent Level 2 extension, not a Series-only exception. Poster shelves become 652 px high and service-entry shelves 316 px. Captions and frame scroll offsets use the same heading band. Home headings remain unchanged. Every Discover lane, including Movies for you, Series for you, service entries, service-specific lanes and Hidden picks, uses a **104 px heading band**: heading line plus **60 px heading-to-card gap**, up from 40 px. This is a consistent Level 2 extension, not a Series-only exception. Poster shelves become 652 px high and service-entry shelves 316 px. Captions and frame scroll offsets use the same heading band. Home headings remain unchanged.
Round-2 Discover navigation amendment: Up/Down into an untouched lane starts at its leftmost item, not the previous lane's nearest column. A moved lane restores its own selected item and horizontal track for this visit. Pushed titles and service/collection pages preserve that visit; selecting another root tab or rebuilding Discover clears its lane positions. Root vertical position, filters and hero selection retain their existing shell policy. Home's nearest-centre shelves and Library's column-preserving continuous grids are unchanged pending a separate behavior decision.
## Motion and loading — revision 6 ## Motion and loading — revision 6
Home heroes and shelves, Library rows, Discover hero/lanes, title headers, episodes and cast, and person headers/filmography use shaped placeholders without loading sentences. The placeholder fill is `surface2` (`#1b1b25`). A complete shelf shares **one** alpha layer: **0.55 → 1 → 0.55 over 1400 ms**, CSS ease-in-out (`.42,0,.58,1`), 700 ms each way. All loading shelves under the screen's theme share one clock, so late arrivals join the current phase. The clock stops when the last loading shelf leaves. No per-card clocks, staggered pulse phases, shimmer, moving gradient, or animated blur. Home heroes and shelves, Library rows, Discover hero/lanes, title headers, episodes and cast, and person headers/filmography use shaped placeholders without loading sentences. The placeholder fill is `surface2` (`#1b1b25`). A complete shelf shares **one** alpha layer: **0.55 → 1 → 0.55 over 1400 ms**, CSS ease-in-out (`.42,0,.58,1`), 700 ms each way. All loading shelves under the screen's theme share one clock, so late arrivals join the current phase. The clock stops when the last loading shelf leaves. No per-card clocks, staggered pulse phases, shimmer, moving gradient, or animated blur.
@ -64,17 +91,18 @@ Home heroes and shelves, Library rows, Discover hero/lanes, title headers, episo
| Poster caption in / out | 60 ms delay + 180 ms ease-out / 100 ms ease-out | 10 px rise on entry only; exit holds its position, including when entry was interrupted | | Poster caption in / out | 60 ms delay + 180 ms ease-out / 100 ms ease-out | 10 px rise on entry only; exit holds its position, including when entry was interrupted |
| Network artwork only | 160 ms, Coil painter alpha | None; memory, disk and local hits are instant | | Network artwork only | 160 ms, Coil painter alpha | None; memory, disk and local hits are instant |
| Row/tab content reveal | 180 ms, ease-out | One layer per row, 24 px rise | | Row/tab content reveal | 180 ms, ease-out | One layer per row, 24 px rise |
| Initial hero/title copy | 180 ms, ease-out | 16 px rise | | Initial hero/title copy | 180 ms, ease-out | 16 px rise (`copyEntryRise`); saved hero entry state suppresses replay on remount, not first appearance |
| Hero backdrop hand-off | 320 ms, ease-in-out (`.65,0,.35,1`) | Alpha only; retain previous art until incoming art or its final fallback is ready | | Hero backdrop hand-off | 320 ms, ease-in-out (`.65,0,.35,1`) | Alpha only; retain previous art until incoming art or its final fallback is ready |
| Hero copy hand-off | Old copy out 120 ms ease (`.25,.1,.25,1`); new copy starts at 140 ms and enters over 200 ms ease-out | 14 px rise on entry | | Hero copy hand-off | Outgoing 120 ms ease (`.25,.1,.25,1`); incoming starts after 140 ms and enters over 200 ms ease-out, gated on artwork or final fallback readiness | One copy slot: no overlapping text; 14 px incoming rise, outgoing translation held where entry stopped |
| Late ratings, genres, overview and readiness | 200 ms, ease | Alpha only, inside existing measured slots; rating plates keep their existing plate fade rather than adding a nested fade | | Late ratings, genres, overview and readiness | 200 ms, ease | Alpha only, inside existing measured slots; rating plates keep their existing plate fade rather than adding a nested fade |
| Horizontal / vertical focus-follow scroll | 160 ms ease / 180 ms ease-out | Existing scroll geometry | | Horizontal / vertical focus-follow scroll | 160 ms ease / 180 ms ease-out | Existing scroll geometry |
| Discover pager | 180 ms, ease-out, retargeting current width and opacity | Buttons and pager remain outside the copy transition; reduced motion snaps width and caps opacity fades at 80 ms |
Row reveals are grouped rather than the browser prototype's per-card staging: the armv7 frame budget permits one animated layer per shelf, not one per card. Tabs have no full-screen transition layer. The upper Home hero blend is bounded to **1920×820 px**, Discover to **1920×740 px**; the lower backdrop changes without an animated screen-sized layer. Hero copy is a separate bounded block. Image reveal uses Coil's painter, not a new full-screen compositing layer. Existing poster-only blur is static. Row reveals are grouped rather than the browser prototype's per-card staging: the armv7 frame budget permits one animated layer per shelf, not one per card. Tabs have no full-screen transition layer. The upper Home hero blend is bounded to **1920×820 px**, Discover to **1920×740 px**; the entire painted backdrop stays static while incoming artwork loads, and the lower backdrop changes only when artwork or its final fallback is ready, without an animated screen-sized layer. Hero copy is a separate bounded block with stationary action controls. Ordinary image reveal uses Coil's painter; hero-controlled requests bypass that independent fade so only the token-backed hero blend owns the transition. Existing poster-only blur is static.
The title page's scroll dim is a draw-only colour overlay, not an alpha-animated full-screen layer. Discover scrolls its backdrop through placement offsets rather than a full-screen translation layer. Initial loading animated layers are Home **2**, Library **up to 3** lazy rows, Discover **2**, title header **1**, person **2**; pending title episode/cast shelves add **1 each**, and each pending Discover lane owns **1**. Deep Home loading is bounded to **4** visible shelf layers. These are animated layers, not independent pulse clocks or every static display-list node: one shared pulse clock drives the loading screen without adding a layer. A hero hand-off adds at most **2** bounded owners (outgoing art and copy). Focus changes can animate **2** card transforms, **1** caption and at most **2** rating plates; artwork fades are painter draws without additional compositing layers. Late title metadata adds at most **4** bounded alpha layers: title ratings, play-target ratings, genres and overview. No animated owner is full-screen. The title page's scroll dim is a draw-only colour overlay, not an alpha-animated full-screen layer. Discover scrolls its backdrop through placement offsets rather than a full-screen translation layer. Initial loading animated layers are Home **2**, Library **up to 3** lazy rows, Discover **2**, title header **1**, person **2**; pending title episode/cast shelves add **1 each**, and each pending Discover lane owns **1**. Deep Home loading is bounded to **4** visible shelf layers. These are animated layers, not independent pulse clocks or every static display-list node: one shared pulse clock drives the loading screen without adding a layer. An ordinary hero hand-off animates **2** bounded owners (outgoing art and one sequential copy block) above an opaque artwork base. Art interruptions retain the painted composite without resetting alpha; each outgoing art owner keeps its original 320 ms lifetime. Copy retargets its current alpha, fades out before replacing text, then uses the approved delay/entry tokens; never two copy layers are visible together. Focus changes can animate **2** card transforms, **1** caption and at most **2** rating plates; ordinary artwork fades are painter draws without additional compositing layers. Late title metadata adds at most **4** bounded alpha layers: title ratings, play-target ratings, genres and overview. No animated owner is full-screen.
The series readiness band reserves **56 px (28 dp)** from its first content paint, including when empty or when readiness later disappears. Ratings remain inside the fixed-height facts band. The series header's measured height therefore cannot change when late ratings/readiness arrive; overview, actions, season and episode anchors retain their coordinates. Skeletons have no click/hover focus targets; pending episode/cast rows remain absent from the key-navigation state until real cards exist. Initial loading keeps nav focus and Back still pops the title/person route. The approved title follow-up (2026-11-07) supersedes the fixed readiness reservation: an absent readiness line occupies **0 px**, while a present line occupies **56 px (28 dp)**. Late arrival/removal animates that height over the **200 ms late-line duration with token ease-out**; reduced motion snaps. Overview, actions and following rows move monotonically with that band; an already-pinned focused row stays pinned through same-remeasure scroll compensation. Ratings remain inside the fixed-height facts band and appear once: an explicit episode page uses its opened episode's own set, including the episode score, rather than the series-only set. Series and episode pages have no redundant Seasons shortcut; Down reaches the season chips. Skeletons have no click/hover focus targets; pending episode/cast rows remain absent from the key-navigation state until real cards exist. Initial title/person loading retains destination focus rather than focusing the root nav; Down cannot enter a skeleton, and Back still pops the route without opening the picker. An already-ready catalogue or held title stays Ready while a same-route, same-person refresh reads new data; the existing signed-in authentication guard remains authoritative, and loaded copy/rows do not remount or replay their entry motion.
Discover key traversal skips pending lanes in both directions. If every remaining lane is pending, Down stays on the real filter chips and Back returns to nav; a skeleton must never create an empty-column focus range. Discover key traversal skips pending lanes in both directions. If every remaining lane is pending, Down stays on the real filter chips and Back returns to nav; a skeleton must never create an empty-column focus range.
@ -82,6 +110,8 @@ Discover key traversal skips pending lanes in both directions. If every remainin
Native **1920×1080** before/after frames and the frame-budget inventory are published at [the motion build proof](https://pages.justinvisser.org/wolfskin/tv-motion-build/). Before frames use the pre-change surface sources in this same isolated worktree; after frames use the shipped Compose surfaces. No device access, installation or optimized APK is part of this lane. On-box acceptance remains: no series layout change after first content, Home scrolling no worse than **39.6% janky / p90 65 ms**. Native JVM rendering does not measure that GPU/frame-timing target. Native **1920×1080** before/after frames and the frame-budget inventory are published at [the motion build proof](https://pages.justinvisser.org/wolfskin/tv-motion-build/). Before frames use the pre-change surface sources in this same isolated worktree; after frames use the shipped Compose surfaces. No device access, installation or optimized APK is part of this lane. On-box acceptance remains: no series layout change after first content, Home scrolling no worse than **39.6% janky / p90 65 ms**. Native JVM rendering does not measure that GPU/frame-timing target.
The [round-2 Discover native proof](https://pages.justinvisser.org/wolfskin/tv-round2-discover/) publishes paired 1920×1080 frame strips and exact 16 ms alpha/translation/position series for ready, slow, failed, sustained rapid-selection and reduced-motion scenarios. The reviewed counter-fade remains available separately for comparison with the corrected sequential copy; initial-entry/remount and Home hero series are also published. The regression suite checks an opaque artwork base, at most one visible copy, monotonic per-phase motion, stationary controls and token-bounded outgoing artwork lifetimes. The approved fade-through may briefly have no visible copy; it never paints overlapping titles. These are native-rendering proofs, not on-box frame-pacing measurements.
## Card metadata and bottom state line — revision 6, option B ## Card metadata and bottom state line — revision 6, option B

View file

@ -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 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 APKs remain under `app/build/outputs/apk/default/{release,debug}/`. Debug builds
resolves each SDK independently; putting all three versions in one Gradle
configuration selects only the newest and prevents older-SDK tests from starting.
APKs remain under `app/build/outputs/apk/default/{release,debug,optimized}/`. Debug builds
are not playback-complete and must not be distributed as releases. Upstream's are not playback-complete and must not be distributed as releases. Upstream's
`.github/workflows` and dependency version catalog are unchanged; release builds `.github/workflows` and dependency version catalog are unchanged; release builds
use the catalog's exact Wholphin extension version (currently `0.2.4`). Do not 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 `testDefaultReleaseUnitTest` task. `make test` uses the closest default-flavor
task without changing upstream's test configuration. task without changing upstream's test configuration.
## Household update channel
The household assembly remains `defaultOptimized`: R8/resource shrinking,
non-debuggable, baseline profiles, pinned playback extensions and no debug
fixtures. Its installation identity is `org.justinvisser.wolfskin.tv.opt`.
The persistent external Android debug keystore is now the **household signing
authority**, not a disposable development key. Certificate SHA-256:
`182bac72e292b9ea4d3d4e6383f92eb2161fa28ba5afcff0337cb7d5a9d5341d`.
Never print/copy that keystore into artifacts or regenerate it for a release.
The household wrapper fails if it is missing; protect and escrow the original.
Same package + same signer + strictly higher Android versionCode allows an
in-place upgrade retaining Jellyfin logins, databases and preferences. Never
uninstall, clear data, change identity or replace the key as an update recovery
step. An older ephemeral signer is a separate, owner-approved migration gate;
this change does not prove the TV's installed signer or retained-login behavior.
The dedicated public distribution repository is
`justinzeus/wolfskin-tv-updates`; its default API is
`https://git.justinvisser.org/api/v1/repos/justinzeus/wolfskin-tv-updates/releases/latest`.
Repository creation and each actual publication are separate owner gates.
Neither command creates a repository. Source remains in `justinzeus/wolfskin`.
Initialize the distribution repository's default branch (for example with a
README) before its first draft; Forgejo needs a commit on which to create a
distribution tag. That tag is not the source tag in the separate source repo.
The old production package keeps its old release API and parser. Only the Opt
package migrates the exact old default saved URL; intentional custom URLs remain.
Installed-release notes use the household channel too.
### Build, inspect and reserve a code
```sh
make tv-release VERSION=1.1.1 VERSION_CODE=100001 \
TV_RELEASE_DIR=/var/tmp/wolfskin-tv-release-1.1.1
# Prerequisites only: no build, tag, ledger mutation or publication.
make tv-release VERSION=1.1.1 VERSION_CODE=100001 \
TV_RELEASE_DIR=/var/tmp/wolfskin-tv-release-dry TV_RELEASE_FLAGS=--dry-run
```
Required: a clean committed source checkout, the existing persistent key, native
package credentials through the existing secret mount, no local AAR overrides,
and an explicit code above the committed ledgers at HEAD and freshly fetched
`origin/main`, plus every local consumed-code reservation. Fetch failure refuses
the build, including a dry-run. Known Opt code 65 seeds the ledger; old production
code 28 and Wholphin code 62 belong to different packages. Proposed first household
code 100001 exceeds all three. No household code derives from tag counts.
Only `make tv-release` produces an installable household release; direct optimized
Gradle builds bypass reservation and must never be installed on the household box.
The builder inspects final signed APKs with aapt2/apksigner and checks package,
version/code, signer, non-debuggable flag, minimum SDK, native ABI inventory,
fixture exclusion and both dexopt profiles. It emits four standalone APKs:
`Wolfskin-armeabi-v7a.apk`, `Wolfskin-arm64-v8a.apk`, `Wolfskin-x86_64.apk`,
and universal `Wolfskin.apk`; plus `wolfskin-update.json`, `RELEASE-NOTES.md`
and `SHA256SUMS`. Hashes and sizes come from final signed bytes. Universal
32-bit x86 is not MPV-complete and is not a supported playback ABI.
Manifest schema 1 contains `packageId`, `versionName`, `versionCode`,
`publishedAt` (UTC ISO-8601), immutable `sourceCommit`, `sourceTag`,
`signerSha256`, and `assets[]` with `abi`, `name`, `bytes`, `sha256`, `minSdk`.
All ABI outputs share one code/signer/identity. The local source tag is
`pwolfskin-opt-v<VERSION>`; the distribution tag is `wolfskin-opt-v<VERSION>`.
Before building, the code is consumed in the local Git ref
`refs/wolfskin-tv/version-codes/<code>`. This survives discarding a working-tree
ledger append or checking out an older source commit. A failed real build also
consumes its code: use a higher code rather than deleting the reservation.
After successful packaging the builder appends the code to the tracked ledger.
Commit that reservation and explicitly push the source tag before distribution;
new producer checkouts obtain the committed floor by fetching `origin/main`.
An existing output directory is refused to preserve previous evidence.
Keep `private/` private: build/dependency logs, R8 mapping/effective configuration,
APK metadata/certificate inspection and native/ZIP inventory. Publication uploads
only the seven named public files, never that directory, credentials or keys.
Review release notes and corresponding-source/native links before promotion.
### Explicit draft publication
```sh
make tv-publish TV_RELEASE_DIR=/var/tmp/wolfskin-tv-release-1.1.1 \
TV_PUBLISH_FLAGS=--dry-run
# Only after owner approval to upload:
make tv-publish TV_RELEASE_DIR=/var/tmp/wolfskin-tv-release-1.1.1
# Only after owner approval to publish the verified draft (use its printed id):
make tv-publish TV_RELEASE_DIR=/var/tmp/wolfskin-tv-release-1.1.1 \
TV_PUBLISH_FLAGS='--draft-id <id> --promote'
make tv-tooling-test
```
The publisher reads git's credential helper into process memory, not arguments,
logs or files. It creates a draft, uploads all public files, then downloads and
hashes their bytes while still draft. Existing matching drafts can be resumed
and reverified. Upload/verification failures remain draft. Promotion requires
the explicit flag; then anonymous latest/asset hash checks must pass. A failed
post-promotion check attempts withdrawal to draft and reports failure, not
success. Cross-origin credential forwarding and non-HTTPS URLs are refused.
Before creating/resuming a draft and immediately before promotion, the publisher
anonymously reads latest and its manifest. The bundle code must be strictly newer.
Only a latest 404 followed by a successful empty release list permits a first
release; other errors fail closed. A channel advance during upload leaves the
verified upload draft rather than publishing an equal or lower code.
`--test-server` permits only a loopback HTTP fake Forgejo; production defaults
cannot be redirected to a different repository by a CLI repository argument.
Dry-run never fetches a credential or makes publication requests.
### Client trust and installation
`UpdateController` is the process-scoped UI contract with `StateFlow` state,
shared auto-check preference, refreshed PackageManager installed version and
explicit check/download/cancel/install/retry/resume actions. Its capture fake
does not perform real operations. Classic Opt update controls use that same
controller; the old package retains its original updater.
`release: StateFlow<UpdateUiState.Available?>` retains version, size, localized
date and release notes through download, Ready and installation cancellation.
Verified records persist notes as an optional `releaseNotes` field alongside
the manifest; existing manifest-only records remain readable. `Failed.operation`
is `UpdateOperation.Check`, `Download` or `Install`, never inferred from message text.
Permission and handoff dismissals restore verified Ready without discarding its
APK. Installer ownership is tracked separately from the visible state, so a
dismissed but active system session still blocks a duplicate handoff. Calling
`install()` from PermissionNeeded resolves and opens the permission settings again.
The client uses a dedicated anonymous OkHttp client, validating every redirect:
HTTPS, no URL user-info, no cookies/Jellyfin token/repository credential.
Loopback HTTP is available only through the test transport constructor flag.
It selects armeabi-v7a first when supported, then remaining device ABIs and
a universal fallback, subject to minSdk. Universal fallback is limited to
the channel's supported playback ABIs: armeabi-v7a, arm64-v8a and x86_64.
It cannot make an unsupported processor compatible. Empty-release-list success
is distinct from a latest-endpoint 404, authorization failure, invalid metadata
or up-to-date code.
Downloads are app-private. Retry starts a new truncated partial rather than
concatenating unvalidated ranges; cancel and process recreation discard partials.
Length, SHA-256, APK PackageManager package/version/current signer and manifest
signer must agree before Ready. Restoring Ready re-hashes/re-inspects the APK.
Checksum files on the same server are not an independent trust authority:
the installed Android signer is the installation boundary.
EOF never starts installation. Install requires a separate OK, per-package
unknown-app permission and a confirmation-required PackageInstaller session.
The bytes streamed into that session are checked again against the manifest length
and hash; any mismatch abandons the session before commit and discards invalid Ready.
Returning from permission settings requires another explicit Install press.
Denial, cancellation and missing system handlers retain the verified download.
No broad FileProvider, public Downloads write, silent install or automatic
uninstall is involved. PackageManager is re-read on return; a successful callback
is not proof that the running process is the new version. Real OEM confirmation,
process replacement and retained-login acceptance remain device-walk gates.
### Full-suite test diagnostics ### Full-suite test diagnostics
The Gradle daemon heap (`org.gradle.jvmargs` in `gradle.properties`) is separate The Gradle daemon heap (`org.gradle.jvmargs` in `gradle.properties`) is separate
@ -200,6 +43,12 @@ execution order. Keep worker heap and parallelism unchanged between samples.
`PersonPickerSessionTest` must also pass late in the full app JVM, not just alone `PersonPickerSessionTest` must also pass late in the full app JVM, not just alone
or promoted to the front by previous failures. or promoted to the front by previous failures.
`SavedPeoplePickerTest` publishes pending Compose snapshots and pumps Android's
main queue while awaiting the saved household, just like the session-picker
await above. Its five-second deadline, blocked first network response and saved
identity assertions are unchanged; the virtual frame clock alone does not drain
an IO continuation queued on Android's host looper.
## Saved-person launch and DNS ## Saved-person launch and DNS
Launch restores an unprotected saved Jellyfin login from local storage before Launch restores an unprotected saved Jellyfin login from local storage before
@ -246,22 +95,20 @@ wait for either response. The minified SDK log tag is not stable across builds.
The `optimized` build type inherits `release`: R8 optimization/minification, The `optimized` build type inherits `release`: R8 optimization/minification,
resource shrinking, non-debuggable code, dependency consumer keep rules and resource shrinking, non-debuggable code, dependency consumer keep rules and
dependency baseline profiles. Its package (`org.justinvisser.wolfskin.tv.opt`), dependency baseline profiles. It changes only signing (the Android debug key),
launcher label (`Wolfskin (Opt)`) and persistent signer remain unchanged. It is package (`org.justinvisser.wolfskin.tv.opt`) and launcher label (`Wolfskin (Opt)`).
the household release assembly, with plain versionName `1.1.1` and an explicit The launcher icon/banner and release-compatible version name are unchanged:
monotonic versionCode; source commit identity is recorded separately. the shared upgrade/updater parser rejects extra suffixes such as `-opt`.
`make apk` remains the old `org.justinvisser.wolfskin.tv` production assembly and `make apk` remains the clean, release-key-signed production build. Debug and
release signer. It must never publish to the household channel. 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 Build all four default-flavor outputs in one invocation through the container
with a deliberately non-release code. **Direct optimized builds must never be wrapper, without a release keystore. On a host whose Gradle cache already holds
installed on the household box or published.** Use `make tv-release` for every the pinned native extensions and their metadata:
household installation. On a host whose Gradle cache already holds the pinned
native extensions and their metadata:
```sh ```sh
bash docker/gradle.sh optimized --offline \ bash docker/gradle.sh optimized --offline \
-PtvVersion=1.1.1 -PtvVersionCode=1 \
-PWholphinExtensionsUsername=JustinZeus \ -PWholphinExtensionsUsername=JustinZeus \
-PWholphinExtensionsPassword=unused \ -PWholphinExtensionsPassword=unused \
:app:assembleDefaultOptimized :app:assembleDefaultOptimized
@ -274,8 +121,7 @@ through the existing `ORG_GRADLE_PROJECT_WholphinExtensionsUsername` and
or securely seeded cached artifacts. No release signing inputs are needed. or securely seeded cached artifacts. No release signing inputs are needed.
Without native extension configuration, the existing dependency selection uses 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 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; playback-complete comparison. Inspect native libraries before installing.
do not install these direct-build APKs on the household box.
Outputs are in `app/build/outputs/apk/default/optimized/`: universal, arm64-v8a, Outputs are in `app/build/outputs/apk/default/optimized/`: universal, arm64-v8a,
armeabi-v7a and x86_64. Keep armeabi-v7a and x86_64. Keep
@ -345,15 +191,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 Neither an upstream merge nor its validation build authorizes a release or TV
installation. 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 shell
Wolfskin UI lives in `app/src/wolfskin/java`, beside upstream source. The signed-in Wolfskin UI lives in `app/src/wolfskin/java`, beside upstream source. The signed-in
@ -397,6 +234,12 @@ padding includes it. The 6 px dash / 4 px gap follows the browser's 3:2 stroke-w
ratios. Resting borders are white at 22% opacity; focused borders use ink at 40%. ratios. Resting borders are white at 22% opacity; focused borders use ink at 40%.
The small gear centred beneath the complete people row opens Settings with Requests focused. The small gear centred beneath the complete people row opens Settings with Requests focused.
Down from any avatar reaches it; Up returns to the originating avatar, including
after a Settings visit. Left/Right avatar traversal is unchanged.
The navigation requirement comes from Justin's words: “the settings icon moved,
but pressing down only works from the last profile icon. I want it to always
move there when pressing down in that screen”. The approved gear placement stays
centred beneath the complete people row.
Settings groups hand off to existing upstream pages; Requests opens the household Settings groups hand off to existing upstream pages; Requests opens the household
Coming-in sheet described below. **Advanced settings → Wolfskin screens** is the persistent Coming-in sheet described below. **Advanced settings → Wolfskin screens** is the persistent
rollout switch: turning it off renders upstream Home and library destinations with rollout switch: turning it off renders upstream Home and library destinations with
@ -405,11 +248,55 @@ Settings list. Native Up traversal stays in content until the first focusable ro
then enters the nav. Turning the switch on renders Wolfskin Home and Libraries. then enters the nav. Turning the switch on renders Wolfskin Home and Libraries.
Discover and Requests are integrated Wolfskin surfaces; title hand-offs remain labelled. Discover and Requests are integrated Wolfskin surfaces; title hand-offs remain labelled.
Root content Back focuses the nav; another Back exits the Android task via
`finishAndRemoveTask`, including Discover, Movies, Shows and Anime without a Home
detour. Cold picker Back exits; a reopened picker dismisses to nav first. Pushed
pages pop normally. Child dialogs, title menus, request sheets and unsaved-setting
prompts keep Back priority. Singleton full-screen pages also exit; they cannot
fall through to the older setup/user UI.
Source — Justin: “pressing back enough times should close wolfskin, currently
there is no way to go back to projectivity apart from weird methods via the old ui”.
`rememberWolfskinBackHandler` owns Android Back at the app root, including a
single-entry `NavDisplay`; full-screen hand-offs use the same `NavigationManager`
exit event rather than exposing the parent setup stack. `WolfskinRootExitTest`
mounts actual Wolfskin Home/Discover/library surfaces and covers picker,
hardware Back and Coming-in sheet precedence. `WolfskinBackStackTest` mounts
the real Updates and Home Settings pages to cover parent pops and unsaved edits.
Saved Jellyfin identities use the existing upstream session. Conductarr supplies Saved Jellyfin identities use the existing upstream session. Conductarr supplies
household labels, requester capabilities and library audiences after sign-in. household labels, requester capabilities and library audiences after sign-in.
The locked shell projection in `app/src/debug/assets/wolfskin-shell.json` is used The locked shell projection in `app/src/debug/assets/wolfskin-shell.json` is used
only when the Advanced debug fixture switch is enabled; release excludes it. only when the Advanced debug fixture switch is enabled; release excludes it.
### Household setup and sign-in recovery
With **Wolfskin screens** enabled, `MainContent` intercepts both upstream setup
destinations before their stock server/user selectors can render. Cold start,
restart with automatic sign-in disabled, the old drawer's user action, legacy
sign-out-equivalent state, setup fallbacks and intent failures all use Wolfskin's
own picker. Entering it does not call `SwitchUserViewModel.init` or clear the
remembered server/user preference.
Setup reuses the supplied/current/recent household server, recovering the most
recently used Room server if the legacy picker erased the preference. Saved people
render without a network request. Only an install with no saved server gets the
one-time Jellyfin URL field; it uses existing Jellyfin discovery and Room
persistence, not a new compiled endpoint. Conductarr remains on its existing
configured URL and enriches the signed-in shell as before.
Selecting a protected profile still requires its PIN or server credentials.
Expired credentials during saved-session revalidation or foreground refresh
return to the picker without allowing a late failure to replace another person's
session. A failed selection offers Wolfskin **Try again** and **Sign in** actions;
hidden/unsaved profiles can use username/password sign-in through the setup
picker's Settings gear. An outage never replaces the saved household endpoint.
Turning **Wolfskin screens** off retains the unchanged upstream
`SwitchServerContent` and `SwitchUserContent` setup screens for rollback. Debug
fixtures still seed the existing fixture picker after authentication.
Person selection immediately clears the old Conductarr identity and Jellyfin Person selection immediately clears the old Conductarr identity and Jellyfin
read gate before saved-login I/O. Detached clients are disposed exactly once on read gate before saved-login I/O. Detached clients are disposed exactly once on
the application's IO scope, after the replacement (or signed-out) identity is the application's IO scope, after the replacement (or signed-out) identity is
@ -515,7 +402,10 @@ attribution and project URL. The check does not depend on incremental merge file
## Wolfskin Home ## Wolfskin Home
Home is one lazy frame: a non-rotating resume → Next up → newest → pick hero, Home is one lazy frame: a non-rotating resume → Next up → newest → pick hero,
Jellyfin shelves, per-destination "Your …" shelves and the Discover continuation. one merged **Continue watching** lane, newest and per-destination Jellyfin shelves,
and the Discover continuation. The watch lane uses landscape tiles, prefers a
resumable episode over another next-up episode from the same series, and sorts
the chosen items by latest actual watch activity. The hero stays off the lane.
The final action opens the real merged Discover screen at its top through the The final action opens the real merged Discover screen at its top through the
shell's root re-selection transition. Home's See all cards open `WolfskinSeeAllLists`, carrying shell's root re-selection transition. Home's See all cards open `WolfskinSeeAllLists`, carrying
the complete underlying list rather than the visible shelf window. Title content the complete underlying list rather than the visible shelf window. Title content
@ -524,27 +414,45 @@ remains the later-slice target.
All Home components and immutable screen models live in `:wolfskin-design`. All Home components and immutable screen models live in `:wolfskin-design`.
The shell's remembered view owns vertical position, each row's selected card and The shell's remembered view owns vertical position, each row's selected card and
actual horizontal `LazyListState`, and the bounded six-hour ratings session cache. actual horizontal `LazyListState`, and the bounded six-hour ratings session cache.
Composing destinations bind `rememberedView(route)` before navigation effects run, so a Every `NavEntry` installs `ScreenEntry` with its own `rememberedView(route)` and
new See all grid cannot borrow or clamp Home's scroller. Re-entry restores the active state before navigation effects run. Home, Library, Discover, title and
remembered track before the page with `scrollToItem`, preserving exact indices and person surfaces consume that entry's viewport; an outgoing page cannot borrow
pixel offsets rather than animating back. Android focus stays with the shell; Home's scroller during Back. Only the active entry registers logical key handling
cards use its logical focus, without requests against uncomposed lazy items. or acquires Android focus. Re-entry restores the remembered horizontal track
with `scrollToItem`, preserving exact indices and pixel offsets. Cards use the
shell's logical focus without requests against uncomposed lazy items.
Back preserves the viewport; returning to Home does not re-run focus-follow over Back preserves the viewport; returning to Home does not re-run focus-follow over
a manually scrolled saved view. The next arrow re-enters visible content when a manually scrolled saved view. The next arrow re-enters visible content when
scrolling moved focus off-screen. scrolling moved focus off-screen.
Pointing changes focus without activation, cards activate on key-up, and held Pointing changes focus without activation, cards activate on key-up, and held
OK events cannot activate twice. OK events cannot activate twice.
Between shelves, an unvisited row selects the nearest visible card center even Between Home shelves, an unvisited row starts at its leftmost card. A visited row
when card widths differ. A visited row restores its column. Horizontal movement restores its column and first-visible position, including after leaving and
keeps the track still while focus remains in its visible middle. returning to the Home tab; Home memory is not reset on tab leave. Library keeps
standard grid navigation. Horizontal movement keeps the track still while focus
remains in its visible middle. Title/person Android Back handlers use the same
active-entry ownership flag, so outgoing pages cannot consume a second Back.
The initial skeleton parks focus on the nav. When the first content arrives, The initial skeleton parks focus on the nav. When the first content arrives,
an untouched fresh entry focuses hero button zero; intervening nav input prevents an untouched fresh entry focuses hero button zero; intervening nav input prevents
that automatic transfer. Home, Libraries and Discover use the same `ShellContentFocus` that automatic transfer. Home, Libraries and Discover use the same `ShellContentFocus`
release/repeat guard and Back ladder. release/repeat guard and Back ladder.
Landscape title scrims include the reserved state-line area inside their gradient
rather than leaving an exposed image strip below it. Their top padding is the
shared Home token (18 dp); opacity remains the approved 92%.
The legacy opt-out Home and its presets also use one combined watch row. Saved
local/server/web/plugin split rows migrate at their first position, preserving
that row's display options and all unrelated rows. Old serialized row names stay
decodable; settings no longer offer separate lanes or a Split action.
`HomeReadFacade` confines Home SDK reads to the selected user. Resume and Next up `HomeReadFacade` confines Home SDK reads to the selected user. Resume and Next up
publish independently of slow or failed library shelves; saved metadata still publish independently of slow or failed library shelves; saved metadata still
paints immediately, but a saved hero offers Details until watch data revalidates. paints immediately, but a saved hero offers Details until watch data revalidates.
Series chronology reads the latest actually watched episode, not the next-up
episode's unwatched user data or premiere date. Enrichment is bounded to three
concurrent series reads; absent history leaves the date unknown rather than
inventing a timestamp. The disk projection preserves series IDs and watch dates,
and still accepts older cache entries without either field.
Home and title Play actions resolve the current Jellyfin item and its current Home and title Play actions resolve the current Jellyfin item and its current
checkpoint at the click, preserving millisecond positions. Series actions use checkpoint at the click, preserving millisecond positions. Series actions use
live NextUp with resumable episodes enabled; an explicitly opened episode stays live NextUp with resumable episodes enabled; an explicitly opened episode stays
@ -806,24 +714,31 @@ The [revision-5 amendment](../data/tv-spec/spec.md) and
[native frame prototype delta](../data/tv-v2/feedback-2026-11-06/index.html) [native frame prototype delta](../data/tv-v2/feedback-2026-11-06/index.html)
retain the approved frames except for the requested feedback changes: retain the approved frames except for the requested feedback changes:
- The picker gear is centred under everyone and uses the picker-person ring. - The picker gear retains its approved whole-row centring and picker-person ring.
Down from every avatar reaches it; Up returns to the originating avatar.
- Shelf tracks share second-last-slot lookahead, count clamping and settled - Shelf tracks share second-last-slot lookahead, count clamping and settled
offsets. Home records completed frame positioning rather than an animation offsets. Home records completed frame positioning rather than an animation
that may still be cancelled; first mount restores the intended shelf item. that may still be cancelled; first mount restores the intended shelf item.
Discover uses all six approved poster slots. Person keeps the final column Discover uses all six approved poster slots. Person keeps the final column
at full poster width. at full poster width.
- Episode images choose episode Primary, then Thumb, then the already-read - Episode images choose episode Primary, then Thumb, then the already-read series Backdrop.
series Backdrop. Episode cards are 400×280 px; cast retains its 68 px clearance. Title episode cards now use 448×280 px, preserving the taller approved crop.
Episode context uses the episode overview, with two summary lines on the Focused summaries ellipsise to one line, the show-name overlay is absent, and
focused card. Held-series headers measure their copy/actions and leave 40 px `S1 E3 · duration` plus the watched check share one line.
before seasons instead of reserving a fixed empty 900 px header. Ready title headers measure their copy/actions and leave 40 px before the
following section. Missing readiness lines collapse completely on every title
kind; late lines expand/collapse over the 200 ms late-line token with ease-out,
or snap with reduced motion. Already-pinned rows compensate in the same
remeasure rather than bouncing through a second focus-follow animation.
Poster-only movies floor that stack so Cast circles clear the poster by at
least 20 px, including held movie fallbacks and short overviews.
- Every Discover heading uses a 104 px band, including a 60 px title-to-card - Every Discover heading uses a 104 px band, including a 60 px title-to-card
gap. Home heading geometry is unchanged. gap. Home heading geometry is unchanged.
- Hero/backdrop decoding is bounded to 1920×1080; upright hero sheets request - Hero/backdrop decoding is bounded to 1920×1080; upright hero sheets request
420×630. The shared request builder replaces conflicting Jellyfin size/encoding 420×630. The shared request builder replaces conflicting Jellyfin size/encoding
parameters: cards use WebP quality 80, while hero backdrops/sheets retain JPEG parameters: cards use WebP quality 80, while hero backdrops/sheets retain JPEG
quality 96. Poster/tile/wide/episode requests are 256×384, 400×225, 544×306 and quality 96. Poster/tile/wide/episode requests are 256×384, 400×225, 544×306 and
400×280; Jellyfin fill preserves aspect ratio before the UI crops. Tags and 448×280; Jellyfin fill preserves aspect ratio before the UI crops. Tags and
authentication query parameters remain intact. TMDB uses w342 for posters and authentication query parameters remain intact. TMDB uses w342 for posters and
portraits, w780 for landscape cards and w1280 for hero backdrops, never original. portraits, w780 for landscape cards and w1280 for hero backdrops, never original.
Lossy WebP decoding is guaranteed below minSdk 23; API 28+ additionally checks Lossy WebP decoding is guaranteed below minSdk 23; API 28+ additionally checks
@ -875,6 +790,28 @@ pop a pushed title/person from content **or** nav. Initial loading has no conten
target: Down stays in nav, while Back can still leave the title. A missing rating target: Down stays in nav, while Back can still leave the title. A missing rating
set emits no layout node, so it cannot reserve an invisible extra facts gap. set emits no layout node, so it cannot reserve an invisible extra facts gap.
The title Menu replaces the old More/media-info icon action and contains Media
info plus Add for someone when requests are permitted. It is shared by held
movie, series and episode pages; non-held pages expose Add for someone in the
same menu while Request/status and Not interested/Show again stay in the row.
Version, tracks, watched and trailer controls keep their existing positions.
Back from Media info returns to the menu; Back from the menu restores its
opening button. The compact menu fades in for 160 ms and out for 100 ms using
the motion ease-out token, with reduced-motion fades capped at 80 ms. Only the
bounded menu panel owns an alpha layer; the scrim is static. Opening another
sheet immediately removes the menu panel and scrim, preventing double darkening.
An empty menu closes if its last permitted action disappears. Controls
use the shared 4 px straddle focus ring. Neither series nor episode details have
a redundant Seasons shortcut; Down reaches the season chips directly. Ratings
appear only in the header facts line, not again beside progress. Explicit episode
pages use the opened episode's own set, including its `Ep` score; movie and series
pages keep their own sets.
Episode shelf skeletons use the same width, height and pitch as ready cards.
Person filmography and related-title shelves remain posters; Home continuation
tiles are a separate component and retain their series-name context.
`TitleReadFacade` confines SDK reads, server/user-scoped last-good disk copies, `TitleReadFacade` confines SDK reads, server/user-scoped last-good disk copies,
person credits and playback mutations to one boundary. `ConductarrTitlePages` person credits and playback mutations to one boundary. `ConductarrTitlePages`
adds real detail, cast, related titles, performer/filmography, readiness/receipts adds real detail, cast, related titles, performer/filmography, readiness/receipts
@ -899,7 +836,10 @@ headers, sheets, cast and poster captions without changing shared type tokens.
Fractional stack placement rounds only the accumulated position, preventing a Fractional stack placement rounds only the accumulated position, preventing a
one-pixel-per-row drift. Focused controls share the canonical lift shadow. one-pixel-per-row drift. Focused controls share the canonical lift shadow.
`TitleKeysTest` replays real Android keys, including modal Back/hold, nearest-row `TitleKeysTest` replays real Android keys, including modal Back/hold, nearest-row
entry, remembered columns, long-sheet visibility and full media-table geometry. entry, remembered columns and long-sheet visibility. `TitleRound2Test` covers
menu Back/focus, preserved media info and household requests, movie stacking,
the Mussolini resume example, request revocation, episode skeleton geometry,
and native menu fade frames sampled in 16 ms steps.
`TitleAsyncFocusTest` delays title and person replies through the real destination `TitleAsyncFocusTest` delays title and person replies through the real destination
and view-model, then exercises content focus and hardware Back. Subtitles Off and view-model, then exercises content focus and hardware Back. Subtitles Off
maps to the player's explicit disabled sentinel, not its unspecified default. maps to the player's explicit disabled sentinel, not its unspecified default.
@ -1118,12 +1058,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 Conductarr operator or a renamed household profile does not hide that user's
own library. A different saved user ID still fails closed. own library. A different saved user ID still fails closed.
`make test` keeps the native capture checks for rendered font sizes and visible `make test` runs `OwnedSourceAuditTest` through the design module's JVM suite.
surface behavior, plus generated-token drift checks. Styling uses generated It scans the owned Kotlin sources in `wolfskin-design/src/main/java` and
tokens rather than raw dimensions. The source-regex wording/style audit was `app/src/wolfskin`, rejecting untokened `.dp`/`.sp` conversions and numeric Color
removed: source spelling is not a consumer-visible behavior contract. constructors outside generated tokens, token font sizes below 12 sp, raw
`LibraryProgressiveSurfaceTest` captures the usable first chunk while the final household library labels, and page-counter/navigation wording. Its negative
read is still suspended. 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 ## Toolchain and disk use
@ -1170,25 +1112,21 @@ the checkout when building on another host, for example:
WOLFSKIN_DEBUG_KEYSTORE="$HOME/.local/share/wolfskin-signing/debug.keystore" make apk-debug 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 wrapper creates a missing key once using the build image's `keytool`: RSA
the build image's `keytool`: RSA 2048, 10,000-day validity, alias 2048, 10,000-day validity, alias `androiddebugkey`, store/key passwords `android`,
`androiddebugkey`, store/key passwords `android`, subject `CN=Android Debug`. and subject `CN=Android Debug`. The containing directory is mode `0700`, the
Household and optimized modes require the existing key and refuse generation. file mode `0600`. Existing keys are reused, never regenerated; generation errors
The directory is mode `0700`, the file mode `0600`; existing keys are reused. abort the build rather than falling back to an ephemeral container key.
Generation errors abort rather than falling back to an ephemeral container key.
The file is bind-mounted read-only at `/tmp/.android/debug.keystore`, with The file is bind-mounted read-only at `/tmp/.android/debug.keystore`, with
`ANDROID_USER_HOME=/tmp/.android`, for every non-release Gradle container. `ANDROID_USER_HOME=/tmp/.android`, for every non-release Gradle container.
Keep and back up this file across clean builds, disposable worktrees, 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 rebuilds and Gradle cache resets. Never commit it. The standard debug passwords
are public; the private key is nevertheless the permanent household signing are public; this key is for local debug/optimized builds, not release signing.
authority. It is distinct from the old production package's release signer.
### Preserve household TV data ### Preserve household TV data
Normal household updates use the in-app verified download and explicit Android Install and update the household TV app only in place with `adb install -r`,
confirmation, preserving the same package and persistent signer. A separately using the same package and persistent debug signer. Its certificate SHA-256 is:
authorized bootstrap may use operator `adb install -r`; that is not a substitute
for the in-app update acceptance walk. The certificate SHA-256 is:
```text ```text
182bac72e292b9ea4d3d4e6383f92eb2161fa28ba5afcff0337cb7d5a9d5341d 182bac72e292b9ea4d3d4e6383f92eb2161fa28ba5afcff0337cb7d5a9d5341d
@ -1206,10 +1144,9 @@ a **keep app data** choice during interactive uninstall. Choose it when
available; older Android versions ignore the attribute. This is a retention available; older Android versions ignore the attribute. This is a retention
prompt, not an automatic backup or a guarantee against data clearing. prompt, not an automatic backup or a guarantee against data clearing.
An operator can compare `dumpsys package <pkg>` before and after an in-place For an in-place update, compare `dumpsys package <pkg>` before and after:
update: `firstInstallTime` must remain unchanged. The in-app service checks `firstInstallTime` must remain unchanged. The guarded installer that checks
package, current signer, checksum and higher code; real-device timestamp and the signer and this timestamp lives in the operator's tooling, not this repo.
retained-login proof remain a separate operator walk, not a JVM-test claim.
## Release credentials ## Release credentials

View file

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

View file

@ -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()

View file

@ -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()

View file

@ -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

View file

@ -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()

View file

@ -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()

View file

@ -19,23 +19,18 @@ android {
} }
kotlin.compilerOptions.jvmTarget = JvmTarget.JVM_11 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 cacheRobolectricSdk by tasks.registering(Copy::class) {
val versions = listOf( from(robolectricSdk)
"9-robolectric-4913185-2-i7",
"11-robolectric-6757853-i7",
"14-robolectric-10818077-i7",
)
// One configuration would evict the older SDKs through version conflict resolution.
from(versions.map { version ->
project.configurations.detachedConfiguration(
project.dependencies.create("org.robolectric:android-all-instrumented:$version"),
).apply { isTransitive = false }
})
into(layout.buildDirectory.dir("robolectric-sdk")) into(layout.buildDirectory.dir("robolectric-sdk"))
} }
val checkTokenDrift by tasks.registering { 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 { doLast {
file("tokens.sha256").readLines().forEach { line -> file("tokens.sha256").readLines().forEach { line ->
val (expected, name) = line.split(" ", limit = 2) val (expected, name) = line.split(" ", limit = 2)
@ -55,6 +50,9 @@ tasks.withType<Test>().configureEach {
} }
dependencies { 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(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui)
implementation(libs.androidx.tv.material) implementation(libs.androidx.tv.material)

View file

@ -121,10 +121,9 @@ def main() -> None:
tokens.update(json.loads((ROOT / "title-tokens.json").read_text())) 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 / "home-tokens.json").read_text()))
tokens.update(json.loads((ROOT / "discover-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) kotlin, css = render_tokens(tokens)
outputs = {str(KOTLIN): kotlin, "tokens.css": css} 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 = {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()}) 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())) outputs["tokens.sha256"] = "".join(f"{checksum} {name}\n" for name, checksum in sorted(checksums.items()))

View file

@ -50,9 +50,9 @@
}, },
"cardOverlayTop": { "cardOverlayTop": {
"$type": "dimension", "$type": "dimension",
"px": 54, "px": 36,
"dp": 27.0, "dp": 18.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289" "source": "TV round 2: lower card bottom gradient; cover the state-line reserve"
}, },
"cardOverlayBottom": { "cardOverlayBottom": {
"$type": "dimension", "$type": "dimension",

View file

@ -99,6 +99,8 @@ class HomeFixture(context: Context) {
resumeSeconds = item.optDouble("resumeSeconds", 0.0).takeIf { it.isFinite() } ?: 0.0, resumeSeconds = item.optDouble("resumeSeconds", 0.0).takeIf { it.isFinite() } ?: 0.0,
played = item.optBoolean("played"), addedAt = item.optString("addedAt", ""), genres = genres(item).toList(), ratings = ratings(item), played = item.optBoolean("played"), addedAt = item.optString("addedAt", ""), genres = genres(item).toList(), ratings = ratings(item),
held = item.optString("availability") == "in_library", tmdbId = item.intOrNull("tmdbId"), held = item.optString("availability") == "in_library", tmdbId = item.intOrNull("tmdbId"),
seriesId = item.stringOrNull("seriesId"),
lastPlayedAt = item.stringOrNull("lastPlayedAt")?.let { java.time.Instant.parse(it).toEpochMilli() },
) )
private fun ratings(item: JSONObject): List<HomeRating> { private fun ratings(item: JSONObject): List<HomeRating> {

View file

@ -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.

View file

@ -98,8 +98,8 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
data.ordered.subList(start, (start + state.windowSize).coerceAtMost(data.ordered.size)) data.ordered.subList(start, (start + state.windowSize).coerceAtMost(data.ordered.size))
} }
LazyColumn(Modifier.clipToBounds().then(if (data.status == ComingInStatus.Unavailable) Modifier.weight(1f, fill = false) else Modifier), verticalArrangement = Arrangement.spacedBy(T.listGap)) { LazyColumn(Modifier.clipToBounds().then(if (data.status == ComingInStatus.Unavailable) Modifier.weight(1f, fill = false) else Modifier), verticalArrangement = Arrangement.spacedBy(T.listGap)) {
itemsIndexed(visibleRows, val keys = visibleRows.map { it.id }.uniqueKeys()
key = { _, item -> item.id }, contentType = { _, _ -> "request" }) { _, item -> itemsIndexed(visibleRows, key = { index, _ -> keys[index] }, contentType = { _, _ -> "request" }) { _, item ->
ComingInRow(item, shell.person, !state.recoveryFocused && item.id == state.focusedId, state.expanded && item.id == state.focusedId, ComingInRow(item, shell.person, !state.recoveryFocused && item.id == state.focusedId, state.expanded && item.id == state.focusedId,
state.ratingsVisible && data.signedIn && item.id == state.focusedId, state.ratingsVisible && data.signedIn && item.id == state.focusedId,
onFocus = { state.focus(item.id) }, onClick = { state.focus(item.id); shell.activateRequest() }) onFocus = { state.focus(item.id) }, onClick = { state.focus(item.id); shell.activateRequest() })

View file

@ -40,7 +40,7 @@ private class CssColumnHeight : ParentDataModifier {
/** Fractional line heights accumulate before rounding, rather than adding a rounded pixel per row. */ /** Fractional line heights accumulate before rounding, rather than adding a rounded pixel per row. */
@Composable @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() } val exact = remember { CssColumnHeight() }
Layout(content, modifier.then(exact)) { children, constraints -> Layout(content, modifier.then(exact)) { children, constraints ->
val measured = Array(children.size) { children[it].measure(constraints.copy(minWidth = 0, minHeight = 0)) } val measured = Array(children.size) { children[it].measure(constraints.copy(minWidth = 0, minHeight = 0)) }

View file

@ -33,6 +33,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import coil3.request.transitionFactory
import coil3.transition.Transition
import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
@ -50,13 +52,14 @@ fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false,
this *= ColorMatrix().apply { setToScale(D.ambientBrightness, D.ambientBrightness, D.ambientBrightness, 1f) } this *= ColorMatrix().apply { setToScale(D.ambientBrightness, D.ambientBrightness, D.ambientBrightness, 1f) }
}) else null } }) else null }
val context = LocalContext.current val context = LocalContext.current
val request = remember(context, path, poster, dim) { val heroReady = LocalHeroArtReady.current
val request = remember(context, path, poster, dim, heroReady != null) {
val upright = poster || dim val upright = poster || dim
WolfskinImages.request(context, path, WolfskinImages.request(context, path,
if (upright) WolfskinTokens.Image.heroPosterRequestWidth else WolfskinTokens.Image.heroRequestWidth, if (upright) WolfskinTokens.Image.heroPosterRequestWidth else WolfskinTokens.Image.heroRequestWidth,
if (upright) WolfskinTokens.Image.heroPosterRequestHeight else WolfskinTokens.Image.heroRequestHeight, upright) if (upright) WolfskinTokens.Image.heroPosterRequestHeight else WolfskinTokens.Image.heroRequestHeight, upright)
.let { if (heroReady != null) it.newBuilder().transitionFactory(Transition.Factory.NONE).build() else it }
} }
val heroReady = LocalHeroArtReady.current
if (painter != null) { if (painter != null) {
androidx.compose.runtime.SideEffect { heroReady?.invoke() } androidx.compose.runtime.SideEffect { heroReady?.invoke() }
Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter) Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter)

View file

@ -1,6 +1,8 @@
// SPDX-License-Identifier: GPL-2.0-only // SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -44,12 +46,17 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
@Composable @Composable
fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) { fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
val shell = state.shell val shell = state.shell
val view = screenView(shell)
val entryActive = LocalScreenActive.current
val focus = remember(state) { object : ShellContentFocus by state {} } val focus = remember(state) { object : ShellContentFocus by state {} }
DisposableEffect(focus) { DisposableEffect(focus, entryActive) {
shell.contentFocus = focus if (entryActive) shell.contentFocus = focus
onDispose { if (shell.contentFocus === focus) { shell.contentFocus = null; shell.contentOverlayVisible = false } } onDispose {
if (shell.tabs.getOrNull(shell.currentTab) != "discover") state.resetLaneMemory()
if (shell.contentFocus === focus) { shell.contentFocus = null; shell.contentOverlayVisible = false }
}
} }
LaunchedEffect(shell.route, state.snapshot.savedAt) { shell.view.savedAt = state.snapshot.savedAt } LaunchedEffect(view, state.snapshot.savedAt) { view.savedAt = state.snapshot.savedAt }
LaunchedEffect(state.toast) { if (state.toast != null) { kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null } } LaunchedEffect(state.toast) { if (state.toast != null) { kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null } }
val focusedTitle = if (state.collection != null) null else state.focusedLane?.titles?.getOrNull(state.column) val focusedTitle = if (state.collection != null) null else state.focusedLane?.titles?.getOrNull(state.column)
val ratingsReader = LocalScreenRatings.current val ratingsReader = LocalScreenRatings.current
@ -78,7 +85,7 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
return return
} }
val density = LocalDensity.current val density = LocalDensity.current
val list = shell.view.listState val list = view.listState
val note = state.snapshot.status in setOf(DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable) val note = state.snapshot.status in setOf(DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)
val heights = remember(state.hasHero, state.lanes, note, state.hasCatalogue) { val heights = remember(state.hasHero, state.lanes, note, state.hasCatalogue) {
buildList { buildList {
@ -103,6 +110,7 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
animateFrameScroll(list, target - with(density) { current.toPx() } - list.firstVisibleItemScrollOffset) animateFrameScroll(list, target - with(density) { current.toPx() } - list.firstVisibleItemScrollOffset)
} }
} }
HeroMotionScope {
Box(modifier.fillMaxSize().testTag("discover-screen")) { Box(modifier.fillMaxSize().testTag("discover-screen")) {
if (state.hasHero && state.service == null && list.firstVisibleItemIndex == 0) state.hero?.let { title -> if (state.hasHero && state.service == null && list.firstVisibleItemIndex == 0) state.hero?.let { title ->
HeroArtCrossfade(title.key, title, L.row_phero, Modifier.offset { IntOffset(0, -list.firstVisibleItemScrollOffset) }) { DiscoverBackdrop(it) } HeroArtCrossfade(title.key, title, L.row_phero, Modifier.offset { IntOffset(0, -list.firstVisibleItemScrollOffset) }) { DiscoverBackdrop(it) }
@ -129,13 +137,15 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
} }
} }
} }
}
} }
@Composable @Composable
private fun DiscoverHero(state: DiscoverState) { private fun DiscoverHero(state: DiscoverState) {
val title = state.hero ?: return val title = state.hero ?: return
HeroCopyHandoff(title.key, title) { copy -> Column(Modifier.fillMaxWidth().height(L.row_phero).padding(start = L.safeSide, bottom = L.heroCopyBottom), verticalArrangement = Arrangement.Bottom) {
Column(Modifier.fillMaxWidth().height(L.row_phero).padding(start = L.safeSide, bottom = L.heroCopyBottom), verticalArrangement = Arrangement.Bottom) { HeroCopyHandoff(title.key, title) { copy ->
Column {
Row(horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap), verticalAlignment = Alignment.CenterVertically) { Row(horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap), verticalAlignment = Alignment.CenterVertically) {
Glyph("seerr", C.accentText, Modifier.size(L.buttonIcon)) Glyph("seerr", C.accentText, Modifier.size(L.buttonIcon))
Text(if (state.snapshot.status == DiscoverStatus.Ready) "For you" else "Trending", color = C.accentText, style = DiscoverType.label) Text(if (state.snapshot.status == DiscoverStatus.Ready) "For you" else "Trending", color = C.accentText, style = DiscoverType.label)
@ -151,17 +161,32 @@ private fun DiscoverHero(state: DiscoverState) {
} }
Spacer(Modifier.height(D.heroMetaGap)) Spacer(Modifier.height(D.heroMetaGap))
Text(copy.overview, Modifier.width(L.heroOverviewMax), color = C.muted, style = DiscoverType.body, maxLines = 2, overflow = TextOverflow.Ellipsis) Text(copy.overview, Modifier.width(L.heroOverviewMax), color = C.muted, style = DiscoverType.body, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
Spacer(Modifier.height(D.heroOverviewGap)) Spacer(Modifier.height(D.heroOverviewGap))
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
DiscoverButton("Details", state.contentFocused && state.focusedRow == 0, "about") { state.openTitle(title) } DiscoverButton("Details", state.contentFocused && state.focusedRow == 0, "about") { state.openTitle(title) }
Row(Modifier.padding(start = D.dotMargin), horizontalArrangement = Arrangement.spacedBy(D.dotGap), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.padding(start = D.dotMargin), horizontalArrangement = Arrangement.spacedBy(D.dotGap), verticalAlignment = Alignment.CenterVertically) {
Glyph("left", C.fg.copy(alpha = if (state.contentFocused && state.focusedRow == 0) 1f else 0f), Modifier.size(D.dotChevron)) Glyph("left", C.fg.copy(alpha = if (state.contentFocused && state.focusedRow == 0) 1f else 0f), Modifier.size(D.dotChevron))
state.heroes.forEachIndexed { index, _ -> Box(Modifier.size(if (index == state.slide) D.dotActive else D.dotSize, D.dotSize).background(if (index == state.slide) C.fg else C.fg.copy(alpha = .3f), CircleShape)) } state.heroes.forEachIndexed { index, _ -> DiscoverHeroDot(index, index == state.slide) }
Glyph("right", C.fg.copy(alpha = if (state.contentFocused && state.focusedRow == 0) 1f else 0f), Modifier.size(D.dotChevron)) Glyph("right", C.fg.copy(alpha = if (state.contentFocused && state.focusedRow == 0) 1f else 0f), Modifier.size(D.dotChevron))
} }
} }
} }
}
/** Pager geometry retargets from its current value independently of artwork loading. */
@Composable
private fun DiscoverHeroDot(index: Int, selected: Boolean) {
val progress = remember { Animatable(if (selected) 1f else 0f) }
val reduced = reducedMotion()
LaunchedEffect(selected, reduced) {
progress.animateTo(if (selected) 1f else 0f,
tween(motionDuration(WolfskinTokens.Motion.focusMove, reduced), easing = WolfskinTokens.Motion.easeOut))
} }
val widthProgress = if (reduced) { if (selected) 1f else 0f } else progress.value
Box(Modifier.testTag("hero-dot-$index").size(D.dotSize + (D.dotActive - D.dotSize) * widthProgress, D.dotSize)
.background(C.fg.copy(alpha = .3f + .7f * progress.value), CircleShape))
} }
@Composable @Composable

View file

@ -40,14 +40,14 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
val height = if (services) K.entryH else K.posterH val height = if (services) K.entryH else K.posterH
val cards = rememberLazyListState() val cards = rememberLazyListState()
val count = if (services) state.snapshot.services.size else lane.titles.size + if (lane.tail == DiscoverTail.None) 0 else 1 val count = if (services) state.snapshot.services.size else lane.titles.size + if (lane.tail == DiscoverTail.None) 0 else 1
val column = (state.shell.view.cards[lane.id] ?: 0).coerceIn(0, (count - 1).coerceAtLeast(0)) val column = state.laneColumn(lane.id).coerceIn(0, (count - 1).coerceAtLeast(0))
val active = state.contentFocused && state.focusedRow == row val active = state.contentFocused && state.focusedRow == row
val slots = K.posterSlots val slots = K.posterSlots
val first = state.shell.view.cards["first:${lane.id}"] ?: 0 val first = state.laneFirsts[lane.id] ?: 0
val density = LocalDensity.current val density = LocalDensity.current
LaunchedEffect(column, count, active) { LaunchedEffect(state.service?.id, column, count, active) {
val target = shelfFirst(column, first, slots, count) val target = shelfFirst(column, first, slots, count)
state.shell.view.cards["first:${lane.id}"] = target state.laneFirsts[lane.id] = target
cards.scrollShelfTo(target, with(density) { K.posterPitch.toPx() }) cards.scrollShelfTo(target, with(density) { K.posterPitch.toPx() })
} }
Box(Modifier.fillMaxWidth().height(D.shelfHeadingBand + height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + L.rowGapAfter).testTag("discover-lane-${lane.id}")) { Box(Modifier.fillMaxWidth().height(D.shelfHeadingBand + height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + L.rowGapAfter).testTag("discover-lane-${lane.id}")) {

Some files were not shown because too many files have changed in this diff Show more