diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 98f03a3a..f2498e02 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -480,6 +480,8 @@ dependencies { tasks.withType().configureEach { dependsOn(":wolfskin-design:testDebugUnitTest") + // Android 16 shared-memory setup needs Robolectric's JDK FileDescriptor bridge. + jvmArgs("--add-exports=java.base/jdk.internal.access=ALL-UNNAMED") systemProperty("robolectric.dependency.dir", project(":wolfskin-design").layout.buildDirectory.dir("robolectric-sdk").get().asFile.absolutePath) systemProperty("robolectric.offline", "true") systemProperty("roborazzi.test.record", "true") diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverSkeletonKeysTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverSkeletonKeysTest.kt index 9dc1786b..bcb0d5a0 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverSkeletonKeysTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverSkeletonKeysTest.kt @@ -43,7 +43,7 @@ class DiscoverSkeletonKeysTest { } press(Key.DirectionDown) rule.runOnIdle { assertEquals(1, state.focusedRow); assertNull(state.cardKey) } - press(Key.Back) + press(Key.Escape) rule.runOnIdle { assertTrue(shell.navFocused); assertFalse(shell.pickerVisible); assertEquals("discover", shell.route) } } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt index 9fbbeaa0..9c10c6c5 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt @@ -84,7 +84,7 @@ class DiscoverTitleTimingTest { assertEquals("related", model.title!!.row) rule.onNodeWithText("More like this").assertIsDisplayed() rule.onRoot().captureRoboImage(File(captures, "discover-title-related.png").absolutePath) - rule.onRoot().performKeyInput { pressKey(Key.Back) } + rule.onRoot().performKeyInput { pressKey(Key.Escape) } rule.waitForIdle() assertNull(discover.title) assertEquals("discover", shell.route) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt index f8322b5f..595a9b32 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt @@ -90,7 +90,7 @@ class HomeSeeAllNavigationTest { try { rule.onNodeWithTag("library-grid").assertIsDisplayed() exerciseGrid(grid) - press(Key.Back) + press(Key.Escape) rule.waitForIdle() assertTrue(navigation.backStack.last() is Destination.Home) assertEquals(row.id, snapshot.rows[shell.view.row].id) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt index 0ef13b60..a6e70319 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt @@ -15,6 +15,7 @@ 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.damontecres.wholphin.ui.nav.Destination import io.mockk.coEvery import io.mockk.mockk import kotlinx.coroutines.CompletableDeferred @@ -47,6 +48,7 @@ import java.util.UUID class TitleAsyncFocusTest { @get:Rule val rule = createAndroidComposeRule() private lateinit var viewModel: WolfskinTitleViewModel + private val navigation = NavigationManager() @After fun dispose() { if (::viewModel.isInitialized) viewModel.viewModelScope.cancel() @@ -58,9 +60,10 @@ class TitleAsyncFocusTest { val facade = mockk() coEvery { facade.readSaved(id, any()) } returns null coEvery { facade.read(id, any(), any()) } coAnswers { ready.await() } - viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk(relaxed = true), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk(relaxed = true)) + viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk(relaxed = true)) val person = ShellPerson("justin", "Justin", "J", false) val route = titleRoute(id.toString()) + navigation.navigateTo(Destination.Wolfskin(route)) val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false pickerLaunch = false @@ -100,9 +103,10 @@ class TitleAsyncFocusTest { val facade = mockk() coEvery { facade.readPersonSaved(id) } returns null coEvery { facade.readPerson(id, 0) } coAnswers { reply.await() } - viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk(relaxed = true), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk(relaxed = true)) + viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk(relaxed = true)) val viewer = ShellPerson("justin", "Justin", "J", false) val route = personRoute(id.toString()) + navigation.navigateTo(Destination.Wolfskin(route)) val shell = ShellState(listOf(viewer), viewer.key).apply { pickerVisible = false pickerLaunch = false @@ -146,9 +150,10 @@ class TitleAsyncFocusTest { reads.incrementAndGet() throw java.io.IOException("offline") } - viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk(relaxed = true), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk(relaxed = true)) + viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk(relaxed = true)) val viewer = ShellPerson("justin", "Justin", "J", false) val route = titleRoute(id.toString()) + navigation.navigateTo(Destination.Wolfskin(route)) val shell = ShellState(listOf(viewer), viewer.key).apply { pickerVisible = false; pickerLaunch = false; enterDestination(route) } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleEntryBackTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleEntryBackTest.kt index 5f5d22dc..d2102c49 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleEntryBackTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleEntryBackTest.kt @@ -1,13 +1,19 @@ package org.justinvisser.wolfskin.tv import android.app.Application +import android.os.Build import android.os.Looper import androidx.activity.ComponentActivity -import androidx.compose.foundation.layout.Box +import androidx.activity.BackEventCompat import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File import androidx.lifecycle.viewModelScope +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.ui.NavDisplay import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences @@ -29,10 +35,12 @@ 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 -/** A loaded outgoing destination remains composed after pop but cannot consume the incoming Home Back. */ +/** Real NavDisplay and system/predictive Back must pop one title/person entry before the root ladder. */ @RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@Config(sdk = [34, 36], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) class TitleEntryBackTest { @get:Rule val rule = createAndroidComposeRule() private lateinit var viewModel: WolfskinTitleViewModel @@ -45,13 +53,50 @@ class TitleEntryBackTest { @Test fun outgoingLoadedTitleCannotConsumeTheNextHomeBack() = verifyOutgoingBack(personPage = false) @Test fun outgoingLoadedPersonCannotConsumeTheNextHomeBack() = verifyOutgoingBack(personPage = true) + @Test fun predictiveTitleBackPopsOnlyOneEntry() = verifyOutgoingBack(personPage = false, predictive = true) + @Test fun predictivePersonBackPopsOnlyOneEntry() = verifyOutgoingBack(personPage = true, predictive = true) - private fun verifyOutgoingBack(personPage: Boolean) { + @Test fun nativeBackKeyWaitsForSystemCompletionBeforePoppingTheTitle() { + val (navigation, shell) = mountTitle(personPage = false) + val before = navigation.backStack.toList() + rule.runOnIdle { rule.activity.dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_BACK)) } + assertEquals("Compose must not pop on native Back key-down before the system completes Back", before, navigation.backStack.toList()) + // Predictive completion is a platform callback, not a raw key-up delivered to Compose. + dispatchBack(predictive = true) + rule.mainClock.advanceTimeBy(400) + assertEquals(listOf(Destination.Home()), navigation.backStack.toList()) + assertFalse(rule.activity.isFinishing) + assertFalse(shell.navFocused) + val captures = File(System.getProperty("wolfskin.captureDirectory"), "system-back").apply { mkdirs() } + rule.onRoot().captureRoboImage(File(captures, "sdk${Build.VERSION.SDK_INT}-title-home.png").absolutePath) + } + + @Test fun immediateSecondSystemBackUsesHomeBeforeExitRecomposition() { + val (navigation, shell) = mountTitle(personPage = false) + rule.runOnIdle { + rule.activity.onBackPressedDispatcher.onBackPressed() + assertEquals(listOf(Destination.Home()), navigation.backStack.toList()) + rule.activity.onBackPressedDispatcher.onBackPressed() + assertTrue("The next Back belongs to Home even before Compose retires the title handler", shell.navFocused) + assertFalse(rule.activity.isFinishing) + } + rule.mainClock.advanceTimeBy(16) + assertFalse(rule.activity.isFinishing) + dispatchBack(predictive = false) + rule.mainClock.advanceTimeBy(16) + assertTrue(rule.activity.isFinishing) + } + + private fun mountTitle(personPage: Boolean): Pair { 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() + when (it) { + ShellAction.Exit -> navigation.requestExit() + ShellAction.Pop -> navigation.goBack() + else -> Unit + } }.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()) @@ -74,16 +119,18 @@ class TitleEntryBackTest { 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) + val onBack = rememberWolfskinBackHandler(shell, navigation) + NavDisplay(backStack = navigation.backStack, onBack = onBack, entryProvider = { entry -> + NavEntry(entry) { + val active = navigation.backStack.lastOrNull() == entry + val entryRoute = if (entry is Destination.Home) "home" else route + LaunchedEffect(entry, active) { if (active) shell.enterDestination(entryRoute) } + ScreenEntry(shell, entryRoute, active) { + if (entry is Destination.Home) ShellSurface(shell) { modifier -> HomeScreen(shell, home, modifier) {} } + else TitleDestination(route, shell, preferences, viewModel = viewModel, fixtures = false) + } } - } + }) } } rule.waitUntil(5_000) { @@ -91,17 +138,37 @@ class TitleEntryBackTest { 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() + return navigation to shell + } + + private fun verifyOutgoingBack(personPage: Boolean, predictive: Boolean = false) { + val activity = rule.activity + val (navigation, shell) = mountTitle(personPage) + dispatchBack(predictive) + rule.mainClock.advanceTimeBy(400) + assertFalse("One system Back from a title/person must never finish the task", activity.isFinishing) rule.waitForIdle() assertEquals(listOf(Destination.Home()), navigation.backStack.toList()) assertFalse(shell.navFocused) - rule.runOnIdle { activity.onBackPressedDispatcher.onBackPressed() } + dispatchBack(predictive) 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() } + dispatchBack(predictive) rule.mainClock.advanceTimeBy(16) assertTrue("Only the following Back from Home nav should exit", activity.isFinishing) } + + private fun dispatchBack(predictive: Boolean) { + rule.runOnIdle { + val dispatcher = rule.activity.onBackPressedDispatcher + if (predictive) { + dispatcher.dispatchOnBackStarted(BackEventCompat(0f, 0f, 0f, BackEventCompat.EDGE_LEFT)) + dispatcher.dispatchOnBackProgressed(BackEventCompat(100f, 0f, .5f, BackEventCompat.EDGE_LEFT)) + } + dispatcher.onBackPressed() + } + } } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt index 272e4a8d..1706f4f0 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt @@ -144,7 +144,7 @@ class TitleKeysTest { assertEquals("Version", state.options[state.column].label) press(Key.DirectionCenter) assertEquals(1, state.sheetIndex) - press(Key.Back) + press(Key.Escape) assertNull(state.sheet) assertEquals("Version", state.options[state.column].label) assertFalse(shell.navFocused) @@ -161,7 +161,7 @@ class TitleKeysTest { assertEquals(2, state.subtitleIndex) press(Key.DirectionUp); press(Key.DirectionCenter) assertEquals(-1, state.subtitleIndex) - press(Key.Back) + press(Key.Escape) assertNull(state.sheet) assertEquals("tracks", state.options[state.column].id) } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt index 91c9387e..12aaadf2 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt @@ -55,7 +55,7 @@ class TitleRound2Test { render() menu() val opener = state.column - press(Key.Back) + press(Key.Escape) assertNull(state.sheet) assertEquals(opener, state.column) assertFalse(shell.navFocused) @@ -64,7 +64,7 @@ class TitleRound2Test { press(Key.DirectionCenter) assertEquals(TitleSheet.MediaInfo, state.sheet) rule.onNodeWithText("Container", useUnmergedTree = true).assertIsDisplayed() - press(Key.Back) + press(Key.Escape) assertEquals(TitleSheet.Menu, state.sheet) press(Key.DirectionDown) press(Key.DirectionCenter) @@ -115,7 +115,7 @@ class TitleRound2Test { rule.onNodeWithTag("title-option-seasons").assertDoesNotExist() menu() rule.onNodeWithText("Add for someone").assertIsDisplayed() - press(Key.Back) + press(Key.Escape) press(Key.DirectionDown) assertEquals("seasons", state.row) rule.onNode(hasText("Season 1") and hasClickAction()).assertIsDisplayed() diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinRootExitTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinRootExitTest.kt index 4e19a94a..fb0b6921 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinRootExitTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinRootExitTest.kt @@ -1,9 +1,9 @@ package org.justinvisser.wolfskin.tv import android.app.Application +import android.os.Build 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 @@ -16,6 +16,8 @@ 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 com.github.takahirom.roborazzi.captureRoboImage +import java.io.File import io.mockk.mockk import java.util.UUID import kotlinx.coroutines.cancel @@ -35,7 +37,7 @@ 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") +@Config(sdk = [34, 36], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") @GraphicsMode(GraphicsMode.Mode.NATIVE) class WolfskinRootExitTest { @get:Rule val rule = createAndroidComposeRule() @@ -73,11 +75,18 @@ class WolfskinRootExitTest { @Test fun hardwareBackUsesTheSameContentNavExitLadder() { mount() val shell = session.state!! - rule.runOnIdle { shell.pickerVisible = false; shell.pickerLaunch = false } - rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Back) } + rule.runOnIdle { shell.pickerVisible = false; shell.pickerLaunch = false; shell.navFocused = false; shell.view.row = 1 } + rule.runOnIdle { activity.dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_BACK)) } + assertFalse("Native Back key-down must not run ahead of system Back completion", shell.navFocused) + rule.runOnIdle { activity.dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_BACK)) } assertTrue(shell.navFocused) assertFalse(activity.isFinishing) - rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Back) } + val captures = File(System.getProperty("wolfskin.captureDirectory"), "system-back").apply { mkdirs() } + rule.onRoot().captureRoboImage(File(captures, "sdk${Build.VERSION.SDK_INT}-home-nav.png").absolutePath) + rule.runOnIdle { + activity.dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_BACK)) + activity.dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_BACK)) + } rule.mainClock.advanceTimeBy(400) assertTrue(activity.isFinishing) assertEquals(listOf(Destination.Home()), navigation.backStack.toList()) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt index 2f6cd82c..53893d73 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt @@ -232,6 +232,12 @@ class WolfskinTitleViewModel @Inject constructor( private fun newPerson(actor: org.justinvisser.wolfskin.design.TitlePerson, credits: List) = PersonState(actor, credits, ::handle) { viewModelScope.launch { readPerson(actor.id) } } + /** A callback retained during exit must hand Back to the live destination, not pop its old title again. */ + internal inline fun handleBack(route: String, shell: ShellState, onCurrentEntry: () -> Unit) { + if (navigation.backStack.last().toWolfskinRoute() == route) onCurrentEntry() + else handleWolfskinBack(shell, navigation) + } + private fun handle(action: TitleAction) { when (action) { TitleAction.Pop -> navigation.goBack() @@ -341,7 +347,9 @@ fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferen } val person = viewModel.person.takeIf { title != null } BackHandler(enabled = isScreenEntryActive() && title != null) { - if (requests?.overlay != null) requests.key(ShellKey.Back) else title?.back(shell) + viewModel.handleBack(route, shell) { + if (requests?.overlay != null) requests.key(ShellKey.Back) else title?.back(shell) + } } Box(modifier.onPreviewKeyEvent { event -> if (requests?.overlay == null || shell.pickerVisible) false else readShellKey(event)?.let { diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index bacbc39a..1eeef88d 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -314,12 +314,7 @@ fun WolfskinApp( // Cached entries must not retain the launch shell after sign-in completes. val state = LocalShellState.current val discover = LocalDiscoverState.current - val route = when (destination) { - is Destination.Home -> "home" - is Destination.Wolfskin -> destination.route - is Destination.MediaItem -> if (destination.type == org.jellyfin.sdk.model.api.BaseItemKind.PERSON) personRoute(destination.itemId.toString()) else titleRoute(destination.itemId.toString()) - else -> "pushed" - } + val route = destination.toWolfskinRoute() val isCurrent = navigationManager.backStack.lastOrNull() == destination LaunchedEffect(destination, state, isCurrent) { if (isCurrent && !destination.fullScreen) state.enterDestination(route) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinBackHandler.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinBackHandler.kt index 8fd409fc..ab74ae56 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinBackHandler.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinBackHandler.kt @@ -6,6 +6,7 @@ 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.nav.Destination import com.github.damontecres.wholphin.ui.findActivity import org.justinvisser.wolfskin.design.ShellKey import org.justinvisser.wolfskin.design.ShellState @@ -18,11 +19,28 @@ internal fun rememberWolfskinBackHandler(shell: ShellState, navigation: Navigati navigation.exitRequests.collect { activity.finishAndRemoveTask() } } val onBack: () -> Unit = remember(shell, navigation) { - { - if (navigation.backStack.lastOrNull()?.fullScreen == true) navigation.goBack() - else shell.key(ShellKey.Back) - } + { handleWolfskinBack(shell, navigation) } } BackHandler(onBack = onBack) return onBack } + +/** Back reads navigation synchronously; an outgoing entry may outlive the composition that disabled it. */ +internal fun handleWolfskinBack(shell: ShellState, navigation: NavigationManager) { + val destination = navigation.backStack.last() + if (destination.fullScreen) { + navigation.goBack() + return + } + val route = destination.toWolfskinRoute() + if (shell.route != route) shell.enterDestination(route) + shell.key(ShellKey.Back) +} + +/** Navigation and Back use the same route identity for each rendered entry. */ +internal fun Destination.toWolfskinRoute(): String = when (this) { + is Destination.Home -> "home" + is Destination.Wolfskin -> route + is Destination.MediaItem -> if (type == org.jellyfin.sdk.model.api.BaseItemKind.PERSON) personRoute(itemId.toString()) else titleRoute(itemId.toString()) + else -> "pushed" +} diff --git a/data/tv-spec/spec.md b/data/tv-spec/spec.md index ff6237db..aee7e387 100644 --- a/data/tv-spec/spec.md +++ b/data/tv-spec/spec.md @@ -12,6 +12,8 @@ Source — Justin (2026-11-07): “the settings icon moved, but pressing down on 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. +Native Android Back (`KEYCODE_BACK`) belongs exclusively to the activity’s system dispatcher, including Navigation 3 predictive completion; Compose key previews must not also navigate on its raw key-down. Escape remains the existing local keyboard Back shortcut. Each completed system Back has one consumer. A retained title/person callback checks the live navigation entry at invocation and hands Back to the incoming entry if its own entry has already popped; it does not depend on an enabled flag catching up with the exit animation. Root handling synchronizes the shell route to the current entry before applying content → nav → task exit. Back ownership is exercised on both SDK 34 (Android 14) and SDK 36. + 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 diff --git a/docker/README.md b/docker/README.md index 490662f4..72f7bf1d 100644 --- a/docker/README.md +++ b/docker/README.md @@ -258,12 +258,19 @@ 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. +The activity's `OnBackPressedDispatcher` owns native `KEYCODE_BACK`. It is not +mapped by `readShellKey`: a Compose key-down cannot navigate before the system +Back callback commits. Escape remains the local keyboard Back shortcut. +`rememberWolfskinBackHandler` resolves the live navigation entry and synchronizes +the shell route before the root nav/exit ladder. A title/person callback retained +during exit forwards to that live entry instead of popping its old title again; +correctness does not depend on a recomposed enabled flag. +`TitleEntryBackTest` mounts real `NavDisplay`, Home and loaded title/person +surfaces; `WolfskinRootExitTest` mounts actual Home/Discover/library surfaces. +Both run on SDK 34 and 36, including system/predictive callbacks, native key +ownership, immediate successive Back before recomposition, picker and Coming-in +precedence. `WolfskinBackStackTest` keeps the real Updates/Home Settings parent +pops and unsaved-edit protection. Saved Jellyfin identities use the existing upstream session. Conductarr supplies household labels, requester capabilities and library audiences after sign-in. @@ -373,21 +380,26 @@ the actual host's suspended fixture load and presses Android OK: it must open th hero rather than activate the nav. Fixture completion does not steal a different destination or nav selection. -Native Roborazzi captures use Robolectric SDK 34 and -`w960dp-h540dp-xhdpi` (1920×1080 at density 2.0). `make test` writes capture PNGs and -the sampled Compose nav-spring curve to `build/wolfskin-captures/`. +Native Roborazzi captures normally use Robolectric SDK 34 and +`w960dp-h540dp-xhdpi` (1920×1080 at density 2.0). System-Back smokes also run SDK 36. +`make test` writes capture PNGs and the sampled Compose nav-spring curve to +`build/wolfskin-captures/`. Home, Library and Discover captures use `home/`, `library/` and `discover/` subdirectories so shared spec state numbers cannot overwrite another renderer's image. Key replays exercise the Android event adapter, including the 6/8/10/11-press shell paths. The profile hand-off capture targets the real `HomeSettingsPage`; Playback is a separate press-count path. -Robolectric SDK 28/30/34 runtimes resolve through Gradle's existing persistent +Robolectric SDK 28/30/34/36 runtimes resolve through Gradle's existing persistent dependency cache, then copy to `wolfskin-design/build/robolectric-sdk/`. Test workers use that local directory in offline mode instead of downloading Android JARs into the container's ephemeral home. A cache miss still needs Maven Central; rate-limited hosts can seed the existing cache from SHA512-verified local Maven artifacts without changing the wrapper or adding volumes. +SDK 36 Back tests use the pinned Android 16 runtime and a test-worker-only +`--add-exports=java.base/jdk.internal.access=ALL-UNNAMED` for Robolectric's +`ApplicationSharedMemory` / JDK FileDescriptor bridge. Other capture suites +retain their existing SDK selection. Default-flavour launcher artwork and English, Hungarian and Polish string overrides live in `app/src/default/res/`. Legacy Wolfskin strings in 25 translated diff --git a/wolfskin-design/build.gradle.kts b/wolfskin-design/build.gradle.kts index d1275edb..9969c0ef 100644 --- a/wolfskin-design/build.gradle.kts +++ b/wolfskin-design/build.gradle.kts @@ -53,6 +53,7 @@ dependencies { robolectricSdk("org.robolectric:android-all-instrumented:9-robolectric-4913185-2-i7") robolectricSdk("org.robolectric:android-all-instrumented:11-robolectric-6757853-i7") robolectricSdk("org.robolectric:android-all-instrumented:14-robolectric-10818077-i7") + robolectricSdk("org.robolectric:android-all-instrumented:16-robolectric-13921718-i7") implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.ui) implementation(libs.androidx.tv.material) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt index f7dbfb1d..ec44774d 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt @@ -32,14 +32,14 @@ import androidx.tv.material3.Text import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L -/** Screens and the shell interpret the same Android directional and activation keys. */ +/** Native Back belongs to the Activity dispatcher; Escape remains the local Back shortcut. */ fun readShellKey(event: KeyEvent): ShellKey? = when (event.key) { Key.DirectionUp -> ShellKey.Up Key.DirectionDown -> ShellKey.Down Key.DirectionLeft -> ShellKey.Left Key.DirectionRight -> ShellKey.Right Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> ShellKey.Ok - Key.Back, Key.Escape -> ShellKey.Back + Key.Escape -> ShellKey.Back else -> null } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt index 8f057bbd..e2aa398b 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt @@ -59,7 +59,7 @@ class DiscoverKeysTest { assertEquals(path.getInt("presses"), keys.length()) launch() repeat(keys.length()) { index -> press(when (keys.getString(index)) { - "OK" -> Key.DirectionCenter; "UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown; "LEFT" -> Key.DirectionLeft; "RIGHT" -> Key.DirectionRight; else -> Key.Back + "OK" -> Key.DirectionCenter; "UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown; "LEFT" -> Key.DirectionLeft; "RIGHT" -> Key.DirectionRight; else -> Key.Escape }) } rule.waitForIdle() } @@ -144,7 +144,7 @@ class DiscoverKeysTest { @Test fun closingAnUncertainReceiptNeverTurnsReopeningIntoAnotherPost() { operations.next = RequestPhase.NotConfirmed replay("Request a pick for yourself") - press(Key.Back, Key.DirectionCenter) + press(Key.Escape, Key.DirectionCenter) assertEquals(RequestPhase.NotConfirmed, (state.overlay as DiscoverOverlayState.Request).state.phase) operations.next = RequestPhase.Receipt press(Key.DirectionCenter) @@ -173,7 +173,7 @@ class DiscoverKeysTest { press(Key.DirectionDown, Key.DirectionCenter) press(Key.DirectionCenter) assertEquals("shared", operations.sent.single().requester) - press(Key.Back, Key.DirectionCenter) + press(Key.Escape, Key.DirectionCenter) press(Key.DirectionDown, Key.DirectionCenter) press(Key.DirectionCenter) assertEquals(1, operations.sent.size) @@ -260,7 +260,7 @@ class DiscoverKeysTest { val origin = shell.view state.openCollection(state.lanes.first { it.id == "pk-movie" }) launch() - press(Key.DirectionDown, Key.Back) + press(Key.DirectionDown, Key.Escape) assertNull(state.collection) assertSame(origin, shell.view) assertEquals(3, shell.view.row) @@ -289,9 +289,9 @@ class DiscoverKeysTest { val moved = state.column press(Key.DirectionCenter) assertNotNull("This round trip must open a title, not a service lane", state.title) - press(Key.Back) + press(Key.Escape) assertEquals(moved, state.column) - press(Key.Back) + press(Key.Escape) press(Key.DirectionLeft, Key.DirectionCenter) assertEquals("home", shell.route) press(Key.DirectionUp, Key.DirectionRight) diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt index a8bcc7a9..9b371f74 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt @@ -219,7 +219,7 @@ class HomeKeysTest { val key = when (name) { "UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown "LEFT" -> Key.DirectionLeft; "RIGHT" -> Key.DirectionRight - "BACK" -> Key.Back; else -> Key.DirectionCenter + "BACK" -> Key.Escape; else -> Key.DirectionCenter } rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeNavigationTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeNavigationTest.kt index f3ed1662..bb96da5c 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeNavigationTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeNavigationTest.kt @@ -44,7 +44,7 @@ class HomeNavigationTest { mount() press(Key.DirectionDown) press(Key.DirectionCenter) - press(Key.Back) + press(Key.Escape) rule.runOnIdle { outgoing = null } rule.waitForIdle() rule.mainClock.autoAdvance = false @@ -80,7 +80,7 @@ class HomeNavigationTest { press(Key.DirectionDown) press(Key.DirectionCenter) rule.onNodeWithTag("title-page").assertIsDisplayed() - press(Key.Back) + press(Key.Escape) rule.runOnIdle { outgoing = null } rule.waitForIdle() assertEquals(2, shell.view.row) @@ -95,7 +95,7 @@ class HomeNavigationTest { val saved = shell.view press(Key.DirectionCenter) rule.onNodeWithTag("title-page").assertIsDisplayed() - press(Key.Back) + press(Key.Escape) assertSame(saved, shell.view) assertEquals(1, shell.view.row) walkDown(start = 1) diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRatingsUiTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRatingsUiTest.kt index 664ecba6..0d3aa24f 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRatingsUiTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRatingsUiTest.kt @@ -46,7 +46,7 @@ class HomeRatingsUiTest { rule.onNodeWithText("7.8").assertDoesNotExist() rule.mainClock.advanceTimeBy(450) rule.onNodeWithText("7.8").assertExists() - rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Back) } + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Escape) } rule.runOnIdle { assertTrue(shell.navFocused) } rule.mainClock.advanceTimeBy(32) rule.onNodeWithText("7.8").assertDoesNotExist() diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeViewportTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeViewportTest.kt index fe86448f..7d5dca54 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeViewportTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeViewportTest.kt @@ -61,7 +61,7 @@ class HomeViewportTest { capture("viewport-${row.id}") press(Key.DirectionCenter) rule.onNodeWithTag("title-page").assertIsDisplayed() - press(Key.Back) + press(Key.Escape) assertVisible(shell, rows) } repeat(rows.lastIndex) { press(Key.DirectionUp); assertVisible(shell, rows) } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryKeysTest.kt index 5eae18b5..dcbbe296 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryKeysTest.kt @@ -77,7 +77,7 @@ class LibraryKeysTest { repeat(6) { press(Key.DirectionRight) } assertEquals(5, grid.index) assertFalse(grid.inRail) - press(listOf(Key.DirectionDown, Key.Back)) + press(listOf(Key.DirectionDown, Key.Escape)) assertTrue(shell.navFocused) assertEquals(11, grid.index) press(Key.DirectionDown) diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/MotionSurfaceTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/MotionSurfaceTest.kt index 14d8f65a..4a301574 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/MotionSurfaceTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/MotionSurfaceTest.kt @@ -74,7 +74,7 @@ class MotionSurfaceTest { assertEquals("header", state.row) assertEquals(0, state.column) } - rule.onRoot().performKeyInput { pressKey(Key.Back) } + rule.onRoot().performKeyInput { pressKey(Key.Escape) } rule.runOnIdle { assertEquals(listOf(TitleAction.Pop), actions); assertFalse(shell.pickerVisible) } } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/PickerNavigationTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/PickerNavigationTest.kt index 61a05809..5f006341 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/PickerNavigationTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/PickerNavigationTest.kt @@ -41,7 +41,7 @@ class PickerNavigationTest { press(Key.DirectionDown) press(Key.DirectionCenter) assertEquals("settings", state.route) - press(Key.Back) + press(Key.Escape) assertTrue(state.pickerVisible) assertTrue(state.gearFocused) press(Key.DirectionUp) diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsKeysTest.kt index f678f68e..cba88883 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsKeysTest.kt @@ -76,11 +76,11 @@ class RequestsKeysTest { send(Key.DirectionCenter) assertFalse(state.requestsVisible) assertTrue(state.route.startsWith("title/")) - send(Key.Back) + send(Key.Escape) rule.onNodeWithTag("coming-row-x6").assertExists() assertEquals("x6", state.requests.focusedId) assertTrue(state.requestsVisible) - send(Key.Back) + send(Key.Escape) assertFalse(state.requestsVisible) assertEquals("settings", state.route) assertEquals(0, state.settingsRow) @@ -126,7 +126,7 @@ class RequestsKeysTest { mount(state) send(Key.DirectionCenter, Key.DirectionUp, Key.DirectionCenter) assertEquals("home", state.route) - send(Key.Back) + send(Key.Escape) assertFalse("Root Back must focus navigation, not reopen the abandoned sheet", state.requestsVisible) assertTrue(state.navFocused) } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ScreenEntryNavigationTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ScreenEntryNavigationTest.kt index 7504b73f..22168261 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ScreenEntryNavigationTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ScreenEntryNavigationTest.kt @@ -58,7 +58,7 @@ class ScreenEntryNavigationTest { val view = shell.view val before = position(route) rule.runOnIdle { shell.enterDestination("title/child"); childVisible = true } - press(Key.Back) + press(Key.Escape) rule.runOnIdle { outgoingVisible = false } rule.waitForIdle() assertSame(view, shell.view) diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellKeysTest.kt index 7b032dce..1bee07b1 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellKeysTest.kt @@ -47,7 +47,7 @@ class ShellKeysTest { } @Test fun settingsBackBackInTenPresses() { - replay(settings + listOf(Key.Back, Key.Back), 10) + replay(settings + listOf(Key.Escape, Key.Escape), 10) assertEquals("home", state.route) assertFalse(state.pickerVisible) assertTrue(state.navFocused) @@ -64,7 +64,7 @@ class ShellKeysTest { replay(listOf(Key.DirectionCenter), 1) val view = state.view rule.runOnIdle { view.row = 4; view.cards["row-4"] = 2 } - rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Back); pressKey(Key.DirectionDown) } + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Escape); pressKey(Key.DirectionDown) } assertSame(view, state.view) assertEquals(4, state.view.row) assertEquals(2, state.view.cards["row-4"])