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.navigation3.runtime.NavBackStack
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Surface
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
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.ui.CoilConfig
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.detail.series.SeasonEpisodeIds
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.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
@ -80,6 +83,7 @@ import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@ -138,6 +142,7 @@ class MainActivity : AppCompatActivity() {
lateinit var screensaverService: ScreensaverService
private var signInAuto = true
private var playerBackend: PlayerBackend? = null
private val json =
Json {
@ -172,7 +177,7 @@ class MainActivity : AppCompatActivity() {
if (!playExternalViewModel.launched.value) {
val lastDest = backStack.lastOrNull()
if (lastDest.isPlayback) {
Timber.v("Restoring back stack with playback")
Timber.v("Restoring back stack without playback")
backStack = backStack.toMutableList().apply { removeAt(lastIndex) }
}
}
@ -209,55 +214,64 @@ class MainActivity : AppCompatActivity() {
Timber.e(ex, "Error with keepScreenOn")
}.launchIn(lifecycleScope)
userPreferencesDataStore.data.collectLatestIn(lifecycleScope) { prefs ->
signInAuto = prefs.signInAutomatically
playerBackend = prefs.playbackPreferences.playerBackend
}
viewModel.appStart()
setContent {
val appPreferences by userPreferencesDataStore.data.collectAsState(null)
if (appPreferences == null) {
// Show loading page if it is taking a while to get app preferences
var showLoading by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(500)
Timber.i("Showing loading page")
showLoading = true
}
if (showLoading) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(Color.Black),
) {
LoadingPage()
Surface(
Modifier
.fillMaxSize()
.background(Color.Black),
) {
val appPreferences by userPreferencesDataStore.data.collectAsState(null)
if (appPreferences == null) {
// Show loading page if it is taking a while to get app preferences
var showLoading by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(500.milliseconds)
Timber.i("Showing loading page")
showLoading = true
}
}
}
appPreferences?.let { appPreferences ->
LaunchedEffect(appPreferences.signInAutomatically) {
signInAuto = appPreferences.signInAutomatically
}
CoilConfig(
prefs = appPreferences,
okHttpClient = okHttpClient,
debugLogging = false,
enableCache = true,
)
LaunchedEffect(appPreferences.debugLogging) {
DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging
}
CompositionLocalProvider(LocalImageUrlService provides imageUrlService) {
WholphinTheme(
true,
appThemeColors = appPreferences.interfacePreferences.appThemeColors,
) {
ProvideLocalClock {
MainContent(
backStack = setupNavigationManager.backStack,
navigationManager = navigationManager,
appPreferences = appPreferences,
backdropService = backdropService,
screensaverService = screensaverService,
modifier = Modifier.fillMaxSize(),
)
if (showLoading) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(Color.Black),
) {
LoadingPage()
}
}
} else {
appPreferences?.let { appPreferences ->
CoilConfig(
prefs = appPreferences,
okHttpClient = okHttpClient,
debugLogging = false,
enableCache = true,
)
LaunchedEffect(appPreferences.debugLogging) {
DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging
}
CompositionLocalProvider(LocalImageUrlService provides imageUrlService) {
WholphinTheme(
true,
appThemeColors = appPreferences.interfacePreferences.appThemeColors,
) {
ProvideLocalClock {
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() {
super.onResume()
Timber.d("onResume")
viewModel.appResume()
lifecycleScope.launchDefault {
screensaverService.pulse()
}
@ -342,8 +357,6 @@ class MainActivity : AppCompatActivity() {
// the back stack rather than crashing in onSaveInstanceState.
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)
}
@ -440,63 +453,71 @@ class MainActivityViewModel
private val backdropService: BackdropService,
private val appUpgradeHandler: AppUpgradeHandler,
) : ViewModel() {
private val mutex = Mutex()
fun appStart() {
viewModelScope.launchDefault {
try {
val needUpgrade = appUpgradeHandler.needUpgrade()
if (needUpgrade) {
showToast(
context,
context.getString(
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(),
mutex.withLock {
try {
val needUpgrade = appUpgradeHandler.needUpgrade()
if (needUpgrade) {
showToast(
context,
context.getString(
R.string.updated_toast,
appUpgradeHandler.currentVersion.toString(),
),
)
if (current != null) {
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)
appUpgradeHandler.run()
}
} else {
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))
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) {
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 {
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 {
@ -504,6 +525,19 @@ class MainActivityViewModel
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

View file

@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.Jellyfin
@ -298,6 +299,16 @@ class ServerRepository
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 {
fun getServerSharedPreferences(context: Context): SharedPreferences =
context.getSharedPreferences(

View file

@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
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.userViewsApi
import org.jellyfin.sdk.model.api.CollectionType
@ -132,13 +133,22 @@ class NavDrawerService
.content.items
val recordingFolders =
if (tvAccess) {
api.liveTvApi
.getRecordingFolders(userId = userId)
.content.items
.map { it.id }
.toSet()
try {
api.liveTvApi
.getRecordingFolders(userId = userId)
.content.items
.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 {
setOf()
emptySet()
}
val libraries =
userViews

View file

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