diff --git a/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt b/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt index 09dc6745..5ccfa603 100644 --- a/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt +++ b/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt @@ -53,9 +53,10 @@ class InstrumentedBasicUiTests { MainContent( backStack = mutableListOf(SetupDestination.ServerList), navigationManager = navigationManager, - appPreferences = mockk(relaxed = true), + userPreferences = mockk(relaxed = true), backdropService = mockk(relaxed = true), screensaverService = screensaverService, + liveSession = MutableStateFlow(null), modifier = Modifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index 4f2a1364..15524556 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -233,7 +233,7 @@ class MainActivity : AppCompatActivity() { playerBackend = prefs.playbackPreferences.playerBackend } - viewModel.appStart(intent) + viewModel.appStart(intent, resuming = savedInstanceState != null) setContent { MaterialTheme { Surface(Modifier.fillMaxSize()) { @@ -283,6 +283,7 @@ class MainActivity : AppCompatActivity() { userPreferences = userPreferences, backdropService = backdropService, screensaverService = screensaverService, + liveSession = viewModel.serverRepository.current, modifier = Modifier.fillMaxSize(), ) } @@ -318,7 +319,7 @@ class MainActivity : AppCompatActivity() { override fun onRestart() { super.onRestart() Timber.d("onRestart") - viewModel.appStart(null) + viewModel.appStart(null, resuming = true) if (!playExternalViewModel.launched.value) { // If restarting during playback that is not external, go back a page val lastDest = navigationManager.backStack.lastOrNull() @@ -440,7 +441,7 @@ class MainActivityViewModel ) : ViewModel() { private val mutex = Mutex() - fun appStart(intent: Intent?) { + fun appStart(intent: Intent?, resuming: Boolean = false) { viewModelScope.launchDefault { mutex.withLock { try { @@ -464,7 +465,7 @@ class MainActivityViewModel } try { - handleAppStart() + handleAppStart(resuming) } catch (ex: Exception) { Timber.e(ex, "Error during appStart") setupNavigationManager.navigateTo(SetupDestination.ServerList) @@ -567,11 +568,16 @@ class MainActivityViewModel * Handle starting the app including auto sign in and navigating to the right starting page */ @VisibleForTesting - internal suspend fun handleAppStart() { + internal suspend fun handleAppStart(resuming: Boolean = false) { val signInAutomatically = preferences.data.first().signInAutomatically val currentUser = serverRepository.current.value - if (signInAutomatically && currentUser != null && !currentUser.user.isProtected) { - // Hot reload + // A picker may be an explicit switch or expired login, even while a user remains cached. + val shown = if (resuming && !signInAutomatically) { + (setupNavigationManager.backStack.lastOrNull() as? SetupDestination.AppContent)?.current + } else null + val canContinue = currentUser != null && shown != null && + currentUser.server.id == shown.server.id && currentUser.user.id == shown.user.id + if ((signInAutomatically || canContinue) && currentUser != null && !currentUser.user.isProtected) { Timber.v("Hot reload restoring state") setupNavigationManager.navigateTo( SetupDestination.AppContent(currentUser), diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt index d2b7c41e..2b653675 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt @@ -11,17 +11,13 @@ import androidx.compose.runtime.CompositionLocalProvider 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.rememberUpdatedState -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator @@ -29,6 +25,7 @@ import androidx.navigation3.ui.NavDisplay import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.data.CurrentUser import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.ScreensaverService @@ -40,7 +37,9 @@ import com.github.damontecres.wholphin.ui.setup.SwitchServerContent import com.github.damontecres.wholphin.ui.setup.SwitchUserContent import com.github.damontecres.wholphin.ui.util.InterfaceCustomization import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization +import kotlinx.coroutines.flow.StateFlow +/** Setup entries carry display snapshots; lifecycle privacy checks use the live repository identity. */ @Composable fun MainContent( backStack: MutableList, @@ -48,6 +47,7 @@ fun MainContent( userPreferences: UserPreferences, backdropService: BackdropService, screensaverService: ScreensaverService, + liveSession: StateFlow, modifier: Modifier = Modifier, setupInterceptor: @Composable (SetupDestination, Modifier) -> Boolean = { key, contentModifier -> WolfskinSetupContent(key, contentModifier) }, ) { @@ -104,16 +104,7 @@ fun MainContent( backdropService.clearBackdrop() } val current = key.current - var showContent by remember { - mutableStateOf(true) - } - LifecycleEventEffect(Lifecycle.Event.ON_STOP) { - if (!userPreferences.appPreferences.signInAutomatically) { - showContent = false - } - } - - if (showContent) { + SignedInContent(current, liveSession, preferences.appPreferences.signInAutomatically) { WolfskinApp( user = current.user, server = current.server, @@ -121,16 +112,6 @@ fun MainContent( preferences = preferences, modifier = Modifier.fillMaxSize(), ) - } else { - Box( - modifier = Modifier.size(200.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator( - color = MaterialTheme.colorScheme.border, - modifier = Modifier.align(Alignment.Center), - ) - } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/SignedInContent.kt b/app/src/main/java/com/github/damontecres/wholphin/SignedInContent.kt new file mode 100644 index 00000000..2d22ded4 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/SignedInContent.kt @@ -0,0 +1,56 @@ +package com.github.damontecres.wholphin + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.tv.material3.MaterialTheme +import androidx.lifecycle.compose.LifecycleEventEffect +import com.github.damontecres.wholphin.data.CurrentUser +import kotlinx.coroutines.flow.StateFlow + +/** Keep an unprotected live session mounted across external-app hand-offs; protected people re-enter setup. */ +@Composable +internal fun SignedInContent( + current: CurrentUser, + liveSession: StateFlow, + signInAutomatically: Boolean, + content: @Composable () -> Unit, +) { + var showContent by remember(current.server.id, current.user.id) { mutableStateOf(true) } + var hasStopped by remember(current.server.id, current.user.id) { mutableStateOf(false) } + fun hideInvalidSession() { + if (!signInAutomatically) { + val live = liveSession.value + val canContinue = live != null && live.server.id == current.server.id && + live.user.id == current.user.id && !live.user.isProtected + if (!canContinue) showContent = false + } + } + LifecycleEventEffect(Lifecycle.Event.ON_STOP) { + hasStopped = true + hideInvalidSession() + } + LifecycleEventEffect(Lifecycle.Event.ON_START) { + // A newly unlocked entry may paint; a returning entry must recheck protection changed while away. + if (hasStopped) { + hideInvalidSession() + hasStopped = false + } + } + if (showContent) { + content() + } else { + Box(Modifier.size(200.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.border, modifier = Modifier.align(Alignment.Center)) + } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/MainActivityResumeTest.kt b/app/src/test/java/com/github/damontecres/wholphin/MainActivityResumeTest.kt new file mode 100644 index 00000000..c7b0fce0 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/MainActivityResumeTest.kt @@ -0,0 +1,95 @@ +package com.github.damontecres.wholphin + +import androidx.datastore.core.DataStore +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.JellyfinUser +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.update +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.nav.Destination +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset +import io.mockk.* +import java.util.UUID +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +/** Warm continuation retains only an already active, unprotected identity; cold and locked entries stay gated. */ +@OptIn(ExperimentalCoroutinesApi::class) +class MainActivityResumeTest { + private val dispatcher = StandardTestDispatcher() + private val repository = mockk() + private val setup = mockk(relaxed = true) + private val navigation = NavigationManager() + private val preferences = mockk>() + private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null) + private val current = CurrentUser(server, + JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "test-token")) + private val automatic = MutableStateFlow(AppPreferences.getDefaultInstance().update { signInAutomatically = false }) + private val model = MainActivityViewModel(mockk(), preferences, repository, setup, navigation, + mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true)) + + @Before fun configure() { + WholphinDispatchers.configure(dispatcher) + every { preferences.data } returns automatic + every { repository.current } returns MutableStateFlow(current) + every { setup.backStack } returns mutableListOf(SetupDestination.AppContent(current)) + coEvery { repository.getMostRecentServer() } returns RestoredSession.ServerOnly(current.server) + coEvery { repository.restoreSavedSession() } returns RestoredSession.ServerOnly(current.server) + } + + @After fun reset() { WholphinDispatchers.reset() } + + @Test fun warmReturnKeepsTheSignedInPersonAndTitleWithAutomaticSignInOff() = runTest(dispatcher) { + val title = Destination.Wolfskin("title/${UUID.randomUUID()}") + navigation.backStack.add(title) + model.handleAppStart(resuming = true) + verify { setup.navigateTo(SetupDestination.AppContent(current)) } + assertEquals(listOf(Destination.Home(), title), navigation.backStack.toList()) + coVerify(exactly = 0) { repository.restoreSavedSession() } + coVerify(exactly = 0) { repository.getMostRecentServer() } + } + + @Test fun warmReturnCannotUnlockPinOrRequireLoginProfiles() = runTest(dispatcher) { + for (enabled in listOf(false, true)) { + for (user in listOf(current.user.copy(pin = "1234"), current.user.copy(requireLogin = true))) { + automatic.value = automatic.value.update { signInAutomatically = enabled } + val protected = current.copy(user = user) + every { repository.current } returns MutableStateFlow(protected) + every { setup.backStack } returns mutableListOf(SetupDestination.AppContent(protected)) + model.handleAppStart(resuming = true) + verify { setup.navigateTo(SetupDestination.UserList(current.server)) } + verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(protected)) } + } + } + } + + @Test fun recreatedActivityWithoutALiveIdentityStillUsesTheColdStartPicker() = runTest(dispatcher) { + every { repository.current } returns MutableStateFlow(null) + model.handleAppStart(resuming = true) + verify { setup.navigateTo(SetupDestination.UserList(current.server)) } + coVerify(exactly = 0) { repository.restoreSavedSession() } + } + + @Test fun warmReturnDoesNotDismissAnExplicitPickerOrAnotherPersonsEntry() = runTest(dispatcher) { + val other = current.copy(user = current.user.copy(id = UUID.randomUUID())) + for (shown in listOf(SetupDestination.UserList(current.server), SetupDestination.AppContent(other))) { + every { setup.backStack } returns mutableListOf(shown) + model.handleAppStart(resuming = true) + verify { setup.navigateTo(SetupDestination.UserList(current.server)) } + verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(current)) } + } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/SignedInContentTest.kt b/app/src/test/java/com/github/damontecres/wholphin/SignedInContentTest.kt new file mode 100644 index 00000000..c2138f15 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/SignedInContentTest.kt @@ -0,0 +1,108 @@ +package com.github.damontecres.wholphin + +import android.app.Application +import android.content.Intent +import android.content.pm.ActivityInfo +import android.content.pm.ResolveInfo +import android.net.Uri +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.lifecycle.Lifecycle +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import java.util.UUID +import kotlinx.coroutines.flow.MutableStateFlow +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.justinvisser.wolfskin.tv.TitleFixture +import org.justinvisser.wolfskin.tv.TitleTrailer +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** The signed-in title survives a stopped activity without removing protected-person gates. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class SignedInContentTest { + @get:Rule val rule = createAndroidComposeRule() + 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 liveSession = MutableStateFlow(CurrentUser(server, user)) + + private fun mount(person: JellyfinUser = user) { + liveSession.value = CurrentUser(server, person) + val fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) }) + val data = fixture.title("Locke", "justin").copy(trailerUrl = "https://www.youtube.com/watch?v=ab_-defghij") + val shell = ShellState(fixture.people, "justin").apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/${data.item.id}") } + val title = TitleState(data, shell.person) { action -> + if (action is TitleAction.Trailer) assertTrue(TitleTrailer(rule.activity).open(action.url)) + } + rule.setContent { + WolfskinTheme { + SignedInContent(CurrentUser(server, person), liveSession, signInAutomatically = false) { + TitleSurface(title, shell) + } + } + } + rule.onNodeWithTag("title-heading").assertTextEquals("Locke") + } + + private fun stopAndReturn() { + rule.activityRule.scenario.moveToState(Lifecycle.State.CREATED) + rule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED) + rule.waitForIdle() + } + + @Test fun closingTrailerKeepsTheSameTitleMountedWithAutomaticSignInOff() { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube://ab_-defghij")) + val handler = ResolveInfo().apply { + activityInfo = ActivityInfo().apply { packageName = "test.youtube"; name = "test.youtube.Player"; exported = true } + } + shadowOf(rule.activity.packageManager).addResolveInfoForIntent(intent, handler) + mount() + rule.onNodeWithTag("title-option-trailer").performClick() + assertEquals("test.youtube", shadowOf(rule.activity).nextStartedActivity.`package`) + stopAndReturn() + rule.onNodeWithTag("title-heading").assertIsDisplayed().assertTextEquals("Locke") + val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "trailer-return").apply { mkdirs() } + rule.onRoot().captureRoboImage(File(directory, "returned-title.png")) + } + + @Test fun stoppedPinProtectedTitleRemainsHiddenUntilThePickerUnlocksIt() { + mount(user.copy(pin = "1234")) + stopAndReturn() + rule.onNodeWithTag("title-heading").assertDoesNotExist() + } + + @Test fun stoppedRequireLoginTitleRemainsHiddenUntilAuthentication() { + mount(user.copy(requireLogin = true)) + stopAndReturn() + rule.onNodeWithTag("title-heading").assertDoesNotExist() + } + + @Test fun protectionAddedAfterOpeningTheTitleIsReadAtStopNotFromTheOldEntry() { + mount() + liveSession.value = CurrentUser(server, user.copy(pin = "1234")) + stopAndReturn() + rule.onNodeWithTag("title-heading").assertDoesNotExist() + } + + @Test fun profileProtectedWhileAwayDoesNotReappearOnReturn() { + mount() + rule.activityRule.scenario.moveToState(Lifecycle.State.CREATED) + liveSession.value = CurrentUser(server, user.copy(pin = "1234")) + rule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED) + rule.waitForIdle() + rule.onNodeWithTag("title-heading").assertDoesNotExist() + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupNavigationTest.kt index 046168fa..6a2f29b7 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupNavigationTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupNavigationTest.kt @@ -99,7 +99,7 @@ class WolfskinSetupNavigationTest { every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false)) rule.setContent { MainContent(stack, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), - mockk(relaxed = true), screensaver, Modifier, + mockk(relaxed = true), screensaver, liveSession = repository.current, modifier = Modifier, setupInterceptor = { destination, modifier -> WolfskinSetupContent(destination, modifier, model) }) } } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupRollbackTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupRollbackTest.kt index 879404b4..b01e145d 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupRollbackTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupRollbackTest.kt @@ -104,7 +104,7 @@ class WolfskinSetupRollbackTest { every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false)) rule.setContent { MainContent(stack, NavigationManager(), UserPreferences(AppPreferences.getDefaultInstance(), null), - mockk(relaxed = true), screensaver) + mockk(relaxed = true), screensaver, liveSession = MutableStateFlow(null)) } fun awaitHeading(resource: Int) { rule.waitUntil(5_000) {