Improved lifecycle handling & code clean up (#1653)

## Description
Add some improvements related to life cycle handling whenever the app is
resumed:
- Refreshing the server-side user config 
- Refreshing the home page automatically

Also 401/403 errors accessing recording folders will be skipped since
this typically means the user lost access while the app was running.
Refreshing the server-side user config (above) will also help with this.

Finally, there's some general code clean up in `MainActivity` to move
non-UI related code out of Compose.

### Related issues
Closes #1647

### Testing
Emulator

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-07-01 19:18:16 -04:00 • committed by GitHub
parent ad25a1eb7c
commit 0e62b9582c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 161 additions and 104 deletions

View file

@ -27,6 +27,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavBackStack
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Surface
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.PlayerBackend
@ -50,6 +51,7 @@ import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService
import com.github.damontecres.wholphin.ui.CoilConfig import com.github.damontecres.wholphin.ui.CoilConfig
import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.collectLatestIn
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchDefault
@ -72,7 +74,8 @@ import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@ -80,6 +83,7 @@ import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@ -138,6 +142,7 @@ class MainActivity : AppCompatActivity() {
lateinit var screensaverService: ScreensaverService lateinit var screensaverService: ScreensaverService
private var signInAuto = true private var signInAuto = true
private var playerBackend: PlayerBackend? = null
private val json = private val json =
Json { Json {
@ -172,7 +177,7 @@ class MainActivity : AppCompatActivity() {
if (!playExternalViewModel.launched.value) { if (!playExternalViewModel.launched.value) {
val lastDest = backStack.lastOrNull() val lastDest = backStack.lastOrNull()
if (lastDest.isPlayback) { if (lastDest.isPlayback) {
Timber.v("Restoring back stack with playback") Timber.v("Restoring back stack without playback")
backStack = backStack.toMutableList().apply { removeAt(lastIndex) } backStack = backStack.toMutableList().apply { removeAt(lastIndex) }
} }
} }
@ -209,55 +214,64 @@ class MainActivity : AppCompatActivity() {
Timber.e(ex, "Error with keepScreenOn") Timber.e(ex, "Error with keepScreenOn")
}.launchIn(lifecycleScope) }.launchIn(lifecycleScope)
userPreferencesDataStore.data.collectLatestIn(lifecycleScope) { prefs ->
signInAuto = prefs.signInAutomatically
playerBackend = prefs.playbackPreferences.playerBackend
}
viewModel.appStart() viewModel.appStart()
setContent { setContent {
val appPreferences by userPreferencesDataStore.data.collectAsState(null) Surface(
if (appPreferences == null) { Modifier
// Show loading page if it is taking a while to get app preferences .fillMaxSize()
var showLoading by remember { mutableStateOf(false) } .background(Color.Black),
LaunchedEffect(Unit) { ) {
delay(500) val appPreferences by userPreferencesDataStore.data.collectAsState(null)
Timber.i("Showing loading page") if (appPreferences == null) {
showLoading = true // Show loading page if it is taking a while to get app preferences
} var showLoading by remember { mutableStateOf(false) }
if (showLoading) { LaunchedEffect(Unit) {
Box( delay(500.milliseconds)
modifier = Timber.i("Showing loading page")
Modifier showLoading = true
.fillMaxSize()
.background(Color.Black),
) {
LoadingPage()
} }
} if (showLoading) {
} Box(
appPreferences?.let { appPreferences -> modifier =
LaunchedEffect(appPreferences.signInAutomatically) { Modifier
signInAuto = appPreferences.signInAutomatically .fillMaxSize()
} .background(Color.Black),
CoilConfig( ) {
prefs = appPreferences, LoadingPage()
okHttpClient = okHttpClient, }
debugLogging = false, }
enableCache = true, } else {
) appPreferences?.let { appPreferences ->
LaunchedEffect(appPreferences.debugLogging) { CoilConfig(
DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging prefs = appPreferences,
} okHttpClient = okHttpClient,
CompositionLocalProvider(LocalImageUrlService provides imageUrlService) { debugLogging = false,
WholphinTheme( enableCache = true,
true, )
appThemeColors = appPreferences.interfacePreferences.appThemeColors, LaunchedEffect(appPreferences.debugLogging) {
) { DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging
ProvideLocalClock { }
MainContent( CompositionLocalProvider(LocalImageUrlService provides imageUrlService) {
backStack = setupNavigationManager.backStack, WholphinTheme(
navigationManager = navigationManager, true,
appPreferences = appPreferences, appThemeColors = appPreferences.interfacePreferences.appThemeColors,
backdropService = backdropService, ) {
screensaverService = screensaverService, ProvideLocalClock {
modifier = Modifier.fillMaxSize(), MainContent(
) backStack = setupNavigationManager.backStack,
navigationManager = navigationManager,
appPreferences = appPreferences,
backdropService = backdropService,
screensaverService = screensaverService,
modifier = Modifier.fillMaxSize(),
)
}
}
} }
} }
} }
@ -279,6 +293,7 @@ class MainActivity : AppCompatActivity() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
Timber.d("onResume") Timber.d("onResume")
viewModel.appResume()
lifecycleScope.launchDefault { lifecycleScope.launchDefault {
screensaverService.pulse() screensaverService.pulse()
} }
@ -342,8 +357,6 @@ class MainActivity : AppCompatActivity() {
// the back stack rather than crashing in onSaveInstanceState. // the back stack rather than crashing in onSaveInstanceState.
Timber.w(ex, "Could not persist back stack; skipping") Timber.w(ex, "Could not persist back stack; skipping")
} }
val playerBackend =
runBlocking { userPreferencesDataStore.data.firstOrNull() }?.playbackPreferences?.playerBackend
outState.putBoolean(KEY_EXTERNAL_PLAYER, playerBackend == PlayerBackend.EXTERNAL_PLAYER) outState.putBoolean(KEY_EXTERNAL_PLAYER, playerBackend == PlayerBackend.EXTERNAL_PLAYER)
} }
@ -440,63 +453,71 @@ class MainActivityViewModel
private val backdropService: BackdropService, private val backdropService: BackdropService,
private val appUpgradeHandler: AppUpgradeHandler, private val appUpgradeHandler: AppUpgradeHandler,
) : ViewModel() { ) : ViewModel() {
private val mutex = Mutex()
fun appStart() { fun appStart() {
viewModelScope.launchDefault { viewModelScope.launchDefault {
try { mutex.withLock {
val needUpgrade = appUpgradeHandler.needUpgrade() try {
if (needUpgrade) { val needUpgrade = appUpgradeHandler.needUpgrade()
showToast( if (needUpgrade) {
context, showToast(
context.getString( context,
R.string.updated_toast, context.getString(
appUpgradeHandler.currentVersion.toString(), R.string.updated_toast,
), appUpgradeHandler.currentVersion.toString(),
) ),
appUpgradeHandler.run()
}
appUpgradeHandler.copySubfont(false)
val prefs =
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
val profileProtected =
serverRepository.current.value?.user?.let {
it.hasPin || it.requireLogin
} == true
if (prefs.signInAutomatically && !profileProtected) {
val current =
serverRepository.restoreSession(
prefs.currentServerId?.toUUIDOrNull(),
prefs.currentUserId?.toUUIDOrNull(),
) )
if (current != null) { appUpgradeHandler.run()
if (current.user.hasPin || current.user.requireLogin) {
navigationManager.navigateTo(SetupDestination.UserList(current.server))
} else {
// Restored
navigationManager.navigateTo(SetupDestination.AppContent(current))
}
} else {
// Did not restore
navigationManager.navigateTo(SetupDestination.ServerList)
} }
} else { appUpgradeHandler.copySubfont(false)
navigationManager.navigateTo(SetupDestination.Loading) val prefs =
backdropService.clearBackdrop() preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
val currentServerId = prefs.currentServerId?.toUUIDOrNull() val profileProtected =
if (currentServerId != null) { serverRepository.current.value?.user?.let {
val currentServer = it.hasPin || it.requireLogin
serverRepository.serverDao.getServer(currentServerId)?.server } == true
if (currentServer != null) { if (prefs.signInAutomatically && !profileProtected) {
navigationManager.navigateTo(SetupDestination.UserList(currentServer)) val current =
serverRepository.restoreSession(
prefs.currentServerId?.toUUIDOrNull(),
prefs.currentUserId?.toUUIDOrNull(),
)
if (current != null) {
if (current.user.hasPin || current.user.requireLogin) {
navigationManager.navigateTo(SetupDestination.UserList(current.server))
} else {
// Restored
navigationManager.navigateTo(SetupDestination.AppContent(current))
}
} else { } else {
// Did not restore
navigationManager.navigateTo(SetupDestination.ServerList) navigationManager.navigateTo(SetupDestination.ServerList)
} }
} else { } else {
navigationManager.navigateTo(SetupDestination.ServerList) navigationManager.navigateTo(SetupDestination.Loading)
backdropService.clearBackdrop()
val currentServerId = prefs.currentServerId?.toUUIDOrNull()
if (currentServerId != null) {
val currentServer =
serverRepository.serverDao.getServer(currentServerId)?.server
if (currentServer != null) {
navigationManager.navigateTo(
SetupDestination.UserList(
currentServer,
),
)
} else {
navigationManager.navigateTo(SetupDestination.ServerList)
}
} else {
navigationManager.navigateTo(SetupDestination.ServerList)
}
} }
} catch (ex: Exception) {
Timber.e(ex, "Error during appStart")
navigationManager.navigateTo(SetupDestination.ServerList)
} }
} catch (ex: Exception) {
Timber.e(ex, "Error during appStart")
navigationManager.navigateTo(SetupDestination.ServerList)
} }
} }
viewModelScope.launchDefault { viewModelScope.launchDefault {
@ -504,6 +525,19 @@ class MainActivityViewModel
deviceProfileService.mediaCodecCapabilitiesTest.supportsAVC() deviceProfileService.mediaCodecCapabilitiesTest.supportsAVC()
} }
} }
fun appResume() {
viewModelScope.launchDefault {
mutex.withLock {
try {
Timber.v("Updating userDto")
serverRepository.updateUserDto()
} catch (ex: Exception) {
Timber.w(ex, "Error during appResume")
}
}
}
}
} }
private val Destination?.isPlayback: Boolean private val Destination?.isPlayback: Boolean

View file

@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.Jellyfin
@ -298,6 +299,16 @@ class ServerRepository
response.content response.content
} }
/**
* Update [currentUserDto] by querying the server
*/
suspend fun updateUserDto() {
val userDto by apiClient.userApi.getCurrentUser()
_currentUserDto.update {
if (it?.id == userDto.id && currentUser?.id == userDto.id) userDto else it
}
}
companion object { companion object {
fun getServerSharedPreferences(context: Context): SharedPreferences = fun getServerSharedPreferences(context: Context): SharedPreferences =
context.getSharedPreferences( context.getSharedPreferences(

View file

@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
@ -132,13 +133,22 @@ class NavDrawerService
.content.items .content.items
val recordingFolders = val recordingFolders =
if (tvAccess) { if (tvAccess) {
api.liveTvApi try {
.getRecordingFolders(userId = userId) api.liveTvApi
.content.items .getRecordingFolders(userId = userId)
.map { it.id } .content.items
.toSet() .map { it.id }
.toSet()
} catch (ex: InvalidStatusException) {
if (ex.status == 401 || ex.status == 403) {
Timber.w("Got HTTP %s querying for recording folders", ex.status)
emptySet()
} else {
throw ex
}
}
} else { } else {
setOf() emptySet()
} }
val libraries = val libraries =
userViews userViews

View file

@ -44,6 +44,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
@ -103,8 +104,9 @@ fun HomePage(
playlistViewModel: AddPlaylistViewModel = hiltViewModel(), playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
) { ) {
val context = LocalContext.current val context = LocalContext.current
LaunchedEffect(Unit) { LifecycleStartEffect(Unit) {
viewModel.init() viewModel.init()
onStopOrDispose { }
} }
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val loading = state.loadingState val loading = state.loadingState