Keep live title sessions when returning from trailer apps
This commit is contained in:
parent
12a81ca8e1
commit
b6665d016b
8 changed files with 281 additions and 34 deletions
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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<SetupDestination>,
|
||||
|
|
@ -48,6 +47,7 @@ fun MainContent(
|
|||
userPreferences: UserPreferences,
|
||||
backdropService: BackdropService,
|
||||
screensaverService: ScreensaverService,
|
||||
liveSession: StateFlow<CurrentUser?>,
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<CurrentUser?>,
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ServerRepository>()
|
||||
private val setup = mockk<SetupNavigationManager>(relaxed = true)
|
||||
private val navigation = NavigationManager()
|
||||
private val preferences = mockk<DataStore<AppPreferences>>()
|
||||
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<CurrentUser?>(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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ComponentActivity>()
|
||||
private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null)
|
||||
private val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "test-token")
|
||||
private val liveSession = MutableStateFlow<CurrentUser?>(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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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) })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue