Build the approved television Updates screen

This commit is contained in:
justinzeus 2026-11-07 16:27:55 +01:00
parent 7a0cbdf909
commit 19df50c0f1
35 changed files with 1069 additions and 69 deletions

View file

@ -88,7 +88,7 @@ class AnimeAvailabilityTest {
}
}
val model = WolfskinShellViewModel(context, session, mockk(), mockk(), mockk(), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
try {
requireNotNull(model.onForeground(server, user)).join()
assertEquals("$key with $count Anime titles", count > 0, "anime" in session.state!!.tabs)

View file

@ -69,7 +69,7 @@ class AnimeRootRemovalTest {
val navigation = NavigationManager()
val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
try {
model.load(server, user)
requireNotNull(model.onForeground(server, user)).join()

View file

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

View file

@ -91,7 +91,7 @@ class FirstSignInNavigationTest {
val navigation = NavigationManager()
val session = WolfskinSession()
val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true),
LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
val home = HomeSnapshot(listOf(HomeRow("resume", HomeRowKind.Hero, cards = listOf(
HomeTitle("episode", "Me and My Town", "episode", seriesTitle = "Desperate Housewives", season = 5, episode = 9)), heroKind = "resume")))
try {

View file

@ -85,7 +85,7 @@ class PersonPickerSessionTest {
val navigation = remember { NavigationManager() }
val model = remember {
WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)).also { models.add(it) }
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()).also { models.add(it) }
}
DisposableEffect(model) { onDispose { model.viewModelScope.cancel() } }
WolfskinApp(server, current.user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),

View file

@ -87,7 +87,7 @@ class PersonSwitchThreadingTest {
coEvery { libraries.readDestinations(any()) } returns emptyMap()
val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
val clients = mutableListOf<ConductarrClient>()
try {
model.load(server, justin)
@ -163,7 +163,7 @@ class PersonSwitchThreadingTest {
coEvery { libraries.readDestinations(any()) } returns emptyMap()
val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
try {
model.load(server, justin)
requireNotNull(model.onForeground(server, justin)).join()

View file

@ -63,7 +63,7 @@ class SavedPeoplePickerTest {
val session = WolfskinSession()
val navigation = NavigationManager()
val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true),
mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
try {
rule.setContent {
WolfskinApp(server, users.first(), navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),

View file

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

View file

@ -0,0 +1,102 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import org.junit.Assert.*
import org.junit.Test
import org.justinvisser.wolfskin.design.ShellKey
import org.justinvisser.wolfskin.tv.update.*
/** 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") 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 percentageRequiresPositiveTotalAndIsBounded() {
listOf(null, 0L, -1L).forEach { total ->
val ui = presentUpdate(UpdateUiState.Downloading(20_100_000, total), updateReleaseFixture())
assertNull(ui.fraction)
assertTrue(ui.title.isEmpty())
assertNotNull(ui.byteProgress)
}
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

@ -0,0 +1,171 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
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.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.justinvisser.wolfskin.tv.update.*
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Native TV frames use the real shell, updater controller seam and Android key adapter. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class UpdatesCaptureTest {
@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(vararg keys: Key) {
keys.forEach { key -> rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } }
rule.mainClock.advanceTimeBy(320)
rule.waitForIdle()
}
@Test fun settings() { launch(UpdateUiState.Checking, settings = true); capture("settings") }
@Test fun classic() {
launch(UpdateUiState.Checking, settings = true)
repeat(8) { press(Key.DirectionDown) }
assertEquals(9, shell.settingsRow)
assertEquals(4, shell.view.listState.firstVisibleItemIndex)
capture("classic")
}
@Test fun checking() { launch(UpdateUiState.Checking); capture("checking") }
@Test fun current() { launch(UpdateUiState.UpToDate); capture("current") }
@Test fun available() { launch(updateReleaseFixture()); capture("available") }
@Test fun notes() { launch(updateReleaseFixture()); press(Key.DirectionRight); capture("notes") }
@Test fun notesOpen() { launch(updateReleaseFixture()); press(Key.DirectionRight, Key.DirectionCenter); capture("notes-open") }
@Test fun downloading() { launch(UpdateUiState.Downloading(20_100_000, 47_800_000)); capture("downloading") }
@Test fun ready() { launch(UpdateUiState.Ready("1.1.1")); capture("ready") }
@Test fun permission() { launch(UpdateUiState.PermissionNeeded); capture("permission") }
@Test fun handoff() { launch(UpdateUiState.HandingOff); capture("handoff") }
@Test fun cancelled() { launch(UpdateUiState.InstallCancelled); capture("cancelled") }
@Test fun failed() { launch(UpdateUiState.Failed(UpdateFailure.HttpRefused, "The download server refused the request (403).")); capture("failed") }
@Test fun offline() { launch(UpdateUiState.Offline); capture("offline") }
@Test fun noInstaller() { launch(UpdateUiState.NoInstaller); capture("no-installer") }
@Test fun noReleasePublished() { launch(UpdateUiState.NoReleasePublished); capture("no-release") }
@Test fun unknownDownloadLength() { launch(UpdateUiState.Downloading(20_100_000, null)); capture("unknown-total") }
@Test fun autoOn() { launch(updateReleaseFixture()); press(Key.DirectionDown); capture("auto-on") }
@Test fun autoOff() { launch(updateReleaseFixture()); press(Key.DirectionDown, Key.DirectionCenter); capture("auto-off") }
@Test fun updated() {
launch(UpdateUiState.UpToDate)
rule.runOnIdle { controller.showInstalled(InstalledVersion("1.1.1", 100001)) }
rule.mainClock.advanceTimeBy(320)
capture("updated")
}
@Test fun notesTrapRealKeysAndBackPreservesSettingsRowAndScroller() {
launch(updateReleaseFixture())
press(Key.DirectionRight, Key.DirectionCenter, Key.DirectionUp, Key.DirectionRight, Key.DirectionDown)
assertTrue(shell.contentOverlayVisible)
assertFalse(shell.navFocused)
assertTrue(controller.autoCheck.value)
press(Key.Back)
assertFalse(shell.contentOverlayVisible)
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
press(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(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(Key.Back)
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
}
@Test fun nativeProgressAndStateTransitionAtSixteenMillisecondFrames() {
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)) }
assertEquals(12f, rule.onNodeWithTag("update-progress").fetchSemanticsNode().boundsInRoot.height, .01f)
if (target != null) {
repeat(101) { frame -> rule.mainClock.advanceTimeBy(16); capture("motion/download-%03d".format(frame)) }
}
press(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

@ -80,6 +80,7 @@ class WolfskinShellViewModel @Inject constructor(
val seeAll: WolfskinSeeAllLists,
val conductarr: ConductarrSession,
artwork: WolfskinArtwork,
val updates: org.justinvisser.wolfskin.tv.update.UpdateController,
) : ViewModel() {
init { artwork.configure(context) }
val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow
@ -195,7 +196,7 @@ class WolfskinShellViewModel @Inject constructor(
is ShellAction.Destination -> {
val target = if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route)
when {
action.route == "settings" -> navigation.navigateTo(target)
action.route == "settings" || action.route == "updates" -> navigation.navigateTo(target)
action.preservePushed && navigation.backStack.size > 1 -> navigation.backStack[0] = target
else -> navigation.replace(target)
}
@ -262,6 +263,7 @@ fun WolfskinApp(
{ shell, homeServer, homeUser, contentModifier -> HomeDestination(shell, homeServer, homeUser, contentModifier) },
) {
val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle()
val installedVersion by viewModel.updates.installedVersion.collectAsStateWithLifecycle()
LaunchedEffect(server.id, user.id, user.accessToken, ConductarrPreference.baseUrl, ConductarrPreference.fixtures) { viewModel.load(server, user) }
androidx.lifecycle.compose.LifecycleEventEffect(androidx.lifecycle.Lifecycle.Event.ON_RESUME) { viewModel.onForeground(server, user) }
LaunchedEffect(identity?.signedIn, identity?.person) {
@ -329,11 +331,15 @@ fun WolfskinApp(
} else TitleDestination(route, state, preferences)
} else {
ShellSurface(state, handlesContentKeys = upstream == null,
settingsInstalledVersion = installedVersion.versionName,
overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier ->
when {
(upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
destination is Destination.Home -> homeContent(state, server, user, contentModifier)
route == "updates" -> org.justinvisser.wolfskin.tv.update.UpdatesScreen(state, viewModel.updates,
onBack = { state.navFocused = false; navigationManager.goBack() },
onClassic = { navigationManager.navigateTo(handOffDestination("classic")) }, modifier = contentModifier)
route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") ->
WolfskinLibraryContent(androidx.compose.ui.platform.LocalContext.current, route, state, user, "${server.id}:${user.id}", viewModel.libraries, navigationManager, viewModel.seeAll, contentModifier)
destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier)

View file

@ -13,6 +13,7 @@ interface UpdateController {
fun check(manual: Boolean)
fun download()
/** Cancel partial bytes, or dismiss permission/handoff while retaining a verified APK. */
fun cancel()
fun install()
fun retry()

View file

@ -0,0 +1,80 @@
// 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 focusAuto() { autoFocused = true }
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

@ -0,0 +1,83 @@
// 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 -> UpdatePresentation(actions = listOf(UpdateAction("Check for updates", UpdateCommand.Check, true)))
UpdateUiState.Checking -> UpdatePresentation(actions = listOf(back), progress = true)
UpdateUiState.UpToDate -> UpdatePresentation(
title = updatedVersion?.let { "Updated to $it" } ?: "Up to date",
summary = if (updatedVersion != null) "Sign-ins and settings kept." else "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.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 failureTitle(kind: UpdateFailure): String = when (kind) {
UpdateFailure.Network -> "Check failed"
UpdateFailure.HttpRefused, UpdateFailure.InsufficientStorage -> "Download failed"
UpdateFailure.InvalidMetadata, UpdateFailure.UnsupportedAbi -> "Update unavailable"
UpdateFailure.ChecksumMismatch, UpdateFailure.WrongPackage, UpdateFailure.WrongSigner, UpdateFailure.LowerVersionCode -> "Verification failed"
}

View file

@ -0,0 +1,58 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv.update
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.ProgressBarRangeInfo
import androidx.compose.ui.semantics.progressBarRangeInfo
import androidx.compose.ui.semantics.semantics
import org.justinvisser.wolfskin.design.reducedMotion
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
import org.justinvisser.wolfskin.design.WolfskinTokens.Update as U
/** Draw-only bounded track: changing bytes never moves logical or Android focus. */
@Composable
fun UpdateProgress(fraction: Float?, modifier: Modifier = Modifier) {
val reduced = reducedMotion()
val phase = if (reduced) 0f else {
val transition = rememberInfiniteTransition(label = "update-progress")
val animated by transition.animateFloat(0f, 1f,
infiniteRepeatable(tween(if (fraction == null) M.updateScan else M.updateSheen,
easing = if (fraction == null) M.breatheEase else M.linear), RepeatMode.Restart), label = "highlight")
animated
}
Canvas(modifier.fillMaxWidth().height(U.progressHeight).testTag("update-progress")
.semantics { progressBarRangeInfo = fraction?.let { ProgressBarRangeInfo(it, 0f..1f) } ?: ProgressBarRangeInfo.Indeterminate }) {
val radius = CornerRadius(U.progressRadius.toPx())
drawRoundRect(C.surface3, cornerRadius = radius)
val width = size.width * (fraction ?: U.scanFraction)
val left = if (fraction != null || reduced) 0f else width * (-1f + (U.scanEnd + 1f) * phase)
clipRect {
drawRoundRect(C.accent, Offset(left, 0f), Size(width, size.height), radius)
if (!reduced && fraction != null && width > 0f) {
clipRect(0f, 0f, width, size.height) {
val start = width * (-1f + 2f * phase)
drawRect(Brush.horizontalGradient(listOf(Color.Transparent, Color.White.copy(alpha = U.sheenAlpha), Color.Transparent),
startX = start, endX = start + width), size = Size(width, size.height))
}
}
}
}
}

View file

@ -0,0 +1,179 @@
// 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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.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.ui.Alignment
import androidx.compose.ui.Modifier
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.focusRing
import org.justinvisser.wolfskin.design.focusScale
import org.justinvisser.wolfskin.design.motionDuration
import org.justinvisser.wolfskin.design.reducedMotion
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 initialInstalled = remember { installed.versionName }
val updatedVersion = installed.versionName.takeIf { it != initialInstalled }
val presentation = presentUpdate(if (updatedVersion != null) UpdateUiState.UpToDate else state, release, updatedVersion)
val scroll = rememberScrollState()
val scope = rememberCoroutineScope()
val density = LocalDensity.current
val reduced = reducedMotion()
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(motionDuration(M.scroll, reduced), 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)
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)
Column(Modifier.offset(L.safeSide, U.contentTop).size(U.width, U.notesHeight).testTag("update-notes")
.focusRing(true, U.radius, WolfskinTokens.Focus.ringControlRing)
.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.notesTitleBottom))
release?.notes?.lineSequence()?.filter { it.isNotBlank() }?.forEach { paragraph ->
Row(Modifier.padding(vertical = U.notesGap / 2)) {
Text("•", Modifier.width(U.padding / 2), style = T.nav, color = C.muted)
Text(paragraph.removePrefix("- "), Modifier.exactLineBox(T.nav), style = T.nav.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Normal), color = C.muted)
}
}
Text("Sign-in stays on this TV. Android asks before installing.", Modifier.padding(top = U.notesGap), style = T.body, color = C.muted)
}
}