From f9ff04c5b7aeef1793177a85493415f15b99de07 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 18 Mar 2026 18:09:13 -0400 Subject: [PATCH] Fix issues when the system kills the app during playback (#1116) ## Description This fixes some issues restoring the app state after it is killed by the system, such as to free up memory while the screensaver is showing. ### Dev notes Reproduce: 1. Start playing media from beginning 2. Pause at 10 minute mark 3. Wait for screensaver to start 4. Press a button 5. Media starts from beginning Note: It may take a few iterations of 2-4 before the OS destroys the app. If the app is killed during playback, ideally Wholphin restores state to _previous_ page, not playback since restoring playback is complex. Before this PR, the back stack was maintained by Compose's `rememberSerializable` and would be restored _after_ the `PlaybackLifecycleObserver` cleans up the playback destination. This meant that the app would be restored, from scratch, to the playback page. The `PlaybackViewModel` would be initialized from scratch as it was originally called, ie play from beginning. The fix is to save and restore the back stack outside of Compose. ### Related issues Fixes #767 ### Testing Emulator & nvidia shield ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/test/InstrumentedBasicUiTests.kt | 7 ++-- .../damontecres/wholphin/MainActivity.kt | 33 ++++++++++++++++--- .../damontecres/wholphin/MainContent.kt | 4 --- .../wholphin/WholphinDreamService.kt | 7 ++-- .../wholphin/services/NavigationManager.kt | 3 +- .../wholphin/ui/nav/ApplicationContent.kt | 15 --------- 6 files changed, 39 insertions(+), 30 deletions(-) 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 72cf2896..09dc6745 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 @@ -9,7 +9,9 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.pressKey +import androidx.navigation3.runtime.NavBackStack import com.github.damontecres.wholphin.MainContent +import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.ScreensaverState import com.github.damontecres.wholphin.services.SetupDestination @@ -43,16 +45,17 @@ class InstrumentedBasicUiTests { @OptIn(ExperimentalTestApi::class) @Test fun myTest() { + val navigationManager = NavigationManager() + navigationManager.backStack = NavBackStack(Destination.Home()) // Start the app composeTestRule.setContent { WholphinTheme { MainContent( backStack = mutableListOf(SetupDestination.ServerList), - navigationManager = mockk(relaxed = true), + navigationManager = navigationManager, appPreferences = mockk(relaxed = true), backdropService = mockk(relaxed = true), screensaverService = screensaverService, - requestedDestination = Destination.Home(), 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 9cb740ab..e8791bde 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -24,6 +24,7 @@ import androidx.datastore.core.DataStore import androidx.lifecycle.ViewModel import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewModelScope +import androidx.navigation3.runtime.NavBackStack import androidx.tv.material3.ExperimentalTvMaterial3Api import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreference @@ -67,6 +68,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json import okhttp3.OkHttpClient import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.serializer.toUUIDOrNull @@ -127,6 +129,11 @@ class MainActivity : AppCompatActivity() { private var signInAuto = true + private val json = + Json { + classDiscriminator = "_type" + } + @OptIn(ExperimentalTvMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -134,6 +141,23 @@ class MainActivity : AppCompatActivity() { Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}") lifecycle.addObserver(playbackLifecycleObserver) + val backStackStr = savedInstanceState?.getString(KEY_BACK_STACK) + if (backStackStr != null) { + Timber.d("Restoring back stack") + var backStack = json.decodeFromString>(backStackStr) + val lastDest = backStack.lastOrNull() + if (lastDest is Destination.Playback || + lastDest is Destination.PlaybackList || + lastDest is Destination.Slideshow + ) { + backStack = backStack.toMutableList().apply { removeAt(lastIndex) } + } + navigationManager.backStack = NavBackStack(*backStack.toTypedArray()) + } else { + val startDestination = intent?.let(::extractDestination) ?: Destination.Home() + navigationManager.backStack = NavBackStack(startDestination) + } + viewModel.serverRepository.currentUser.observe(this) { user -> if (user?.hasPin == true) { window?.setFlags( @@ -206,17 +230,12 @@ class MainActivity : AppCompatActivity() { appThemeColors = appPreferences.interfacePreferences.appThemeColors, ) { ProvideLocalClock { - val requestedDestination = - remember(intent) { - intent?.let(::extractDestination) ?: Destination.Home() - } MainContent( backStack = setupNavigationManager.backStack, navigationManager = navigationManager, appPreferences = appPreferences, backdropService = backdropService, screensaverService = screensaverService, - requestedDestination = requestedDestination, modifier = Modifier.fillMaxSize(), ) } @@ -287,6 +306,8 @@ class MainActivity : AppCompatActivity() { override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) Timber.d("onSaveInstanceState") + val str = json.encodeToString(navigationManager.backStack.toList()) + outState.putString(KEY_BACK_STACK, str) } override fun onRestoreInstanceState(savedInstanceState: Bundle) { @@ -362,6 +383,8 @@ class MainActivity : AppCompatActivity() { const val INTENT_SEASON_NUMBER = "seaNum" const val INTENT_SEASON_ID = "seaId" + private const val KEY_BACK_STACK = "backStack" + lateinit var instance: MainActivity private set } 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 5bade867..0b32b9ed 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt @@ -33,10 +33,8 @@ import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.ui.components.AppScreensaver import com.github.damontecres.wholphin.ui.nav.ApplicationContent -import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.setup.SwitchServerContent import com.github.damontecres.wholphin.ui.setup.SwitchUserContent -import com.github.damontecres.wholphin.ui.util.ProvideLocalClock @Composable fun MainContent( @@ -45,7 +43,6 @@ fun MainContent( appPreferences: AppPreferences, backdropService: BackdropService, screensaverService: ScreensaverService, - requestedDestination: Destination, modifier: Modifier = Modifier, ) { Surface( @@ -113,7 +110,6 @@ fun MainContent( ApplicationContent( user = current.user, server = current.server, - startDestination = requestedDestination, navigationManager = navigationManager, preferences = preferences, modifier = Modifier.fillMaxSize(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt index a4c09ad4..1a350905 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt @@ -22,9 +22,7 @@ import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.ScreensaverService -import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.components.AppScreensaverContent import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.theme.WholphinTheme @@ -33,6 +31,7 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.first import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import timber.log.Timber import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds @@ -61,6 +60,7 @@ class WholphinDreamService : override fun onCreate() { super.onCreate() + Timber.d("onCreate") savedStateRegistryController.performRestore(null) lifecycleRegistry.currentState = Lifecycle.State.CREATED @@ -74,6 +74,7 @@ class WholphinDreamService : override fun onAttachedToWindow() { super.onAttachedToWindow() + Timber.d("onAttachedToWindow") val itemFlow = screensaverService.createItemFlow(lifecycleScope) setContentView( ComposeView(this).apply { @@ -109,11 +110,13 @@ class WholphinDreamService : override fun onDreamingStarted() { super.onDreamingStarted() + Timber.d("onDreamingStarted") lifecycleRegistry.currentState = Lifecycle.State.STARTED } override fun onDreamingStopped() { super.onDreamingStopped() + Timber.d("onDreamingStopped") lifecycleRegistry.currentState = Lifecycle.State.DESTROYED } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt index a1754298..1706ba52 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt @@ -1,6 +1,5 @@ package com.github.damontecres.wholphin.services -import androidx.navigation3.runtime.NavKey import com.github.damontecres.wholphin.ui.nav.Destination import org.acra.ACRA import timber.log.Timber @@ -14,7 +13,7 @@ import javax.inject.Singleton class NavigationManager @Inject constructor() { - var backStack: MutableList = mutableListOf() + var backStack: MutableList = mutableListOf() /** * Go to the specified [com.github.damontecres.wholphin.ui.nav.Destination] diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt index 5f01f20d..0eafa0cc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.saveable.rememberSerializable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind @@ -28,12 +27,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator -import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavEntry -import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator -import androidx.navigation3.runtime.serialization.NavBackStackSerializer -import androidx.navigation3.runtime.serialization.NavKeySerializer import androidx.navigation3.ui.NavDisplay import androidx.tv.material3.DrawerValue import androidx.tv.material3.MaterialTheme @@ -78,22 +73,12 @@ class ApplicationContentViewModel fun ApplicationContent( server: JellyfinServer, user: JellyfinUser, - startDestination: Destination, navigationManager: NavigationManager, preferences: UserPreferences, modifier: Modifier = Modifier, enableTopScrim: Boolean = true, viewModel: ApplicationContentViewModel = hiltViewModel(), ) { - val backStack: MutableList = - rememberSerializable( - server, - user, - serializer = NavBackStackSerializer(elementSerializer = NavKeySerializer()), - ) { - NavBackStack(startDestination) - } - navigationManager.backStack = backStack val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle() val backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle val drawerState = rememberDrawerState(DrawerValue.Closed)