From 4a6977eefbf0f4f1cb2db153fc37ce6358567a2a Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Sun, 7 Jun 2026 11:25:59 +0000 Subject: [PATCH] feat(connectivity): add offline resilience with server reachability checks --- .../hu/bbara/purefin/ui/screen/AppScreen.kt | 5 +- .../ui/screen/download/DownloadsScreen.kt | 17 ++- .../download/components/DownloadsContent.kt | 3 + .../screen/home/components/DefaultTopBar.kt | 36 ++++++ .../data/CompositeLocalMediaRepository.kt | 103 +++++++++++++++--- .../bbara/purefin/core/data/NetworkMonitor.kt | 1 + .../core/feature/browse/home/AppViewModel.kt | 23 +++- .../catalog/InMemoryAppContentRepository.kt | 85 +++++++++++---- .../jellyfin/ConnectivityNetworkMonitor.kt | 66 +++++++++-- .../DefaultPlayableMediaRepository.kt | 21 +++- .../data/jellyfin/client/JellyfinApiClient.kt | 11 ++ 11 files changed, 317 insertions(+), 54 deletions(-) diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/AppScreen.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/AppScreen.kt index 805c17ff..68d353ff 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/AppScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/AppScreen.kt @@ -13,11 +13,11 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider @@ -46,6 +46,7 @@ fun AppScreen( val nextUp by viewModel.nextUp.collectAsStateWithLifecycle() val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle() val isOnline by viewModel.isOnline.collectAsStateWithLifecycle() + val isCheckingConnection by viewModel.isCheckingConnection.collectAsStateWithLifecycle() val isCheckingForUpdates by updateViewModel.isCheckingForUpdates.collectAsStateWithLifecycle() val availableUpdate by updateViewModel.availableUpdate.collectAsStateWithLifecycle() val navigationManager = LocalNavigationManager.current @@ -133,6 +134,8 @@ fun AppScreen( DownloadsScreen( selectedTab = selectedTab, isOnline = isOnline, + isCheckingConnection = isCheckingConnection, + onCheckConnection = viewModel::checkConnection, onTabSelected = onTabSelected, modifier = Modifier.fillMaxSize() ) diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/download/DownloadsScreen.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/download/DownloadsScreen.kt index 7edd6e79..8f60ba88 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/download/DownloadsScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/download/DownloadsScreen.kt @@ -10,12 +10,15 @@ import androidx.compose.ui.Modifier import hu.bbara.purefin.ui.screen.AppBottomBar import hu.bbara.purefin.ui.screen.download.components.DownloadsContent import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar +import hu.bbara.purefin.ui.screen.home.components.DefaultTopBarTextButton @OptIn(ExperimentalMaterial3Api::class) @Composable fun DownloadsScreen( selectedTab: Int, isOnline: Boolean, + isCheckingConnection: Boolean, + onCheckConnection: () -> Unit, onTabSelected: (Int) -> Unit, modifier: Modifier = Modifier ) { @@ -23,7 +26,19 @@ fun DownloadsScreen( modifier = modifier.fillMaxSize(), containerColor = MaterialTheme.colorScheme.background, contentColor = MaterialTheme.colorScheme.onBackground, - topBar = { DefaultTopBar() }, + topBar = { + DefaultTopBar( + rightActions = { + if (!isOnline) { + DefaultTopBarTextButton( + text = if (isCheckingConnection) "Checking…" else "Check connection", + onClick = onCheckConnection, + enabled = !isCheckingConnection + ) + } + } + ) + }, bottomBar = { AppBottomBar( selectedTab = selectedTab, diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/download/components/DownloadsContent.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/download/components/DownloadsContent.kt index 36c7978f..8e53a1a7 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/download/components/DownloadsContent.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/download/components/DownloadsContent.kt @@ -5,14 +5,17 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Download +import androidx.compose.material3.Card import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/home/components/DefaultTopBar.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/home/components/DefaultTopBar.kt index 68abdf64..0178dd62 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/home/components/DefaultTopBar.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/home/components/DefaultTopBar.kt @@ -5,18 +5,23 @@ import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonColors import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior @@ -114,6 +119,37 @@ fun DefaultTopBarIconButton( } } +@Composable +fun DefaultTopBarTextButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + val scheme = MaterialTheme.colorScheme + + TextButton( + onClick = onClick, + enabled = enabled, + shape = CircleShape, + colors = ButtonDefaults.textButtonColors( + containerColor = scheme.surface, + contentColor = scheme.onSurface, + disabledContainerColor = scheme.surface, + disabledContentColor = scheme.onSurface + ), + contentPadding = PaddingValues(horizontal = 18.dp), + modifier = modifier + .height(50.dp) + .clip(CircleShape), + ) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + ) + } +} + @OptIn(ExperimentalSharedTransitionApi::class) @Composable fun DefaultTopBarSearchButton( diff --git a/core/src/main/java/hu/bbara/purefin/core/data/CompositeLocalMediaRepository.kt b/core/src/main/java/hu/bbara/purefin/core/data/CompositeLocalMediaRepository.kt index 56cfe788..13fee447 100644 --- a/core/src/main/java/hu/bbara/purefin/core/data/CompositeLocalMediaRepository.kt +++ b/core/src/main/java/hu/bbara/purefin/core/data/CompositeLocalMediaRepository.kt @@ -9,12 +9,13 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import java.util.UUID import javax.inject.Inject @@ -25,12 +26,13 @@ import javax.inject.Singleton class CompositeLocalMediaRepository @Inject constructor( @Offline private val offlineRepository: LocalMediaRepository, @Online private val onlineRepository: LocalMediaRepository, + private val networkMonitor: NetworkMonitor, ) : LocalMediaRepository { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - // TODO move this into the domain layer and there you can use NetworkMonitor. Data should be free of android stuff. - private val activeRepository: Flow = flowOf(onlineRepository) + private val activeRepository: Flow = networkMonitor.isOnline + .map { isOnline -> if (isOnline) onlineRepository else offlineRepository } override val movies: StateFlow> = activeRepository .flatMapLatest { it.movies } @@ -45,40 +47,111 @@ class CompositeLocalMediaRepository @Inject constructor( .stateIn(scope, Eagerly, emptyMap()) override suspend fun getMovie(id: UUID): Flow { - return activeRepository - .flatMapLatest { it.getMovie(id) } + return getFromActiveRepository( + onlineRead = { onlineRepository.getMovie(id) }, + offlineRead = { offlineRepository.getMovie(id) }, + ) } override suspend fun getSeries(id: UUID): Flow { - return activeRepository - .flatMapLatest { it.getSeries(id) } + return getFromActiveRepository( + onlineRead = { onlineRepository.getSeries(id) }, + offlineRead = { offlineRepository.getSeries(id) }, + ) } override suspend fun getEpisode(id: UUID): Flow { - return activeRepository - .flatMapLatest { it.getEpisode(id) } + return getFromActiveRepository( + onlineRead = { onlineRepository.getEpisode(id) }, + offlineRead = { offlineRepository.getEpisode(id) }, + ) } override suspend fun loadSeasons(seriesId: UUID) { - activeRepository.first().loadSeasons(seriesId) + runOnlineOrOfflineNoOp( + onlineAction = { onlineRepository.loadSeasons(seriesId) }, + offlineAction = { offlineRepository.loadSeasons(seriesId) }, + ) } override suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) { - activeRepository.first().loadSeasonEpisodes(seriesId, seasonId) + runOnlineOrOfflineNoOp( + onlineAction = { onlineRepository.loadSeasonEpisodes(seriesId, seasonId) }, + offlineAction = { offlineRepository.loadSeasonEpisodes(seriesId, seasonId) }, + ) } override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) { - onlineRepository.updateWatchProgress(mediaId, positionMs, durationMs) + if (networkMonitor.isOnline.first()) { + runOnlineAction { onlineRepository.updateWatchProgress(mediaId, positionMs, durationMs) } + } offlineRepository.updateWatchProgress(mediaId, positionMs, durationMs) } override suspend fun updateWatchProgressPercent(mediaId: UUID, progressPercent: Double) { - onlineRepository.updateWatchProgressPercent(mediaId, progressPercent) + if (networkMonitor.isOnline.first()) { + runOnlineAction { onlineRepository.updateWatchProgressPercent(mediaId, progressPercent) } + } offlineRepository.updateWatchProgressPercent(mediaId, progressPercent) } override suspend fun markAsWatched(mediaId: UUID, watched: Boolean) { - val repository = onlineRepository - repository.markAsWatched(mediaId, watched) + runOnlineOrOfflineNoOp( + onlineAction = { onlineRepository.markAsWatched(mediaId, watched) }, + offlineAction = { offlineRepository.markAsWatched(mediaId, watched) }, + ) + } + + private suspend fun getFromActiveRepository( + onlineRead: suspend () -> Flow, + offlineRead: suspend () -> Flow, + ): Flow { + if (!networkMonitor.isOnline.first()) { + return offlineRead() + } + return try { + onlineRead() + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + if (!networkMonitor.checkConnection()) { + offlineRead() + } else { + throw error + } + } + } + + private suspend fun runOnlineOrOfflineNoOp( + onlineAction: suspend () -> Unit, + offlineAction: suspend () -> Unit, + ) { + if (!networkMonitor.isOnline.first()) { + offlineAction() + return + } + try { + onlineAction() + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + if (!networkMonitor.checkConnection()) { + offlineAction() + } else { + throw error + } + } + } + + private suspend fun runOnlineAction(action: suspend () -> Unit) { + try { + action() + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + if (networkMonitor.checkConnection()) { + throw error + } + } } } diff --git a/core/src/main/java/hu/bbara/purefin/core/data/NetworkMonitor.kt b/core/src/main/java/hu/bbara/purefin/core/data/NetworkMonitor.kt index ffa3e9a2..b25d4529 100644 --- a/core/src/main/java/hu/bbara/purefin/core/data/NetworkMonitor.kt +++ b/core/src/main/java/hu/bbara/purefin/core/data/NetworkMonitor.kt @@ -4,4 +4,5 @@ import kotlinx.coroutines.flow.Flow interface NetworkMonitor { val isOnline: Flow + suspend fun checkConnection(): Boolean } diff --git a/core/src/main/java/hu/bbara/purefin/core/feature/browse/home/AppViewModel.kt b/core/src/main/java/hu/bbara/purefin/core/feature/browse/home/AppViewModel.kt index c8006c9b..dfc35d21 100644 --- a/core/src/main/java/hu/bbara/purefin/core/feature/browse/home/AppViewModel.kt +++ b/core/src/main/java/hu/bbara/purefin/core/feature/browse/home/AppViewModel.kt @@ -43,17 +43,20 @@ class AppViewModel @Inject constructor( private val navigationManager: NavigationManager, private val homeRefreshCoordinator: HomeRefreshCoordinator, private val settingsRepository: SettingsRepository, - networkMonitor: NetworkMonitor, + private val networkMonitor: NetworkMonitor, ) : ViewModel() { private val _isRefreshing = MutableStateFlow(false) val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + private val _isCheckingConnection = MutableStateFlow(false) + val isCheckingConnection: StateFlow = _isCheckingConnection.asStateFlow() + val isOnline: StateFlow = networkMonitor.isOnline .stateIn( viewModelScope, SharingStarted.Eagerly, - false + true ) val serverUrl: StateFlow = userSessionRepository.serverUrl @@ -253,18 +256,34 @@ class AppViewModel @Inject constructor( fun onResumed() { viewModelScope.launch { + networkMonitor.checkConnection() homeRefreshCoordinator.onResumed() } } fun onRefresh() { viewModelScope.launch { + networkMonitor.checkConnection() homeRefreshCoordinator.onRefresh { isRefreshing -> _isRefreshing.value = isRefreshing } } } + fun checkConnection() { + viewModelScope.launch { + if (_isCheckingConnection.value) return@launch + _isCheckingConnection.value = true + val isConnected = networkMonitor.checkConnection() + _isCheckingConnection.value = false + if (isConnected) { + homeRefreshCoordinator.onRefresh { isRefreshing -> + _isRefreshing.value = isRefreshing + } + } + } + } + fun openSearch() { navigationManager.navigate(Route.HomeSearchRoute) } diff --git a/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryAppContentRepository.kt b/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryAppContentRepository.kt index 964c3852..1f3f7ec3 100644 --- a/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryAppContentRepository.kt +++ b/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryAppContentRepository.kt @@ -24,6 +24,7 @@ import hu.bbara.purefin.data.offline.cache.toSeries import hu.bbara.purefin.model.Library import hu.bbara.purefin.model.LibraryKind import hu.bbara.purefin.model.Media +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -51,6 +52,7 @@ class InMemoryAppContentRepository @Inject constructor( private val networkMonitor: NetworkMonitor, ) : HomeRepository { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var cacheLoadJob: Job? = null private var refreshJob: Job? = null @OptIn(ExperimentalAtomicApi::class) @@ -72,9 +74,7 @@ class InMemoryAppContentRepository @Inject constructor( override val latestLibraryContent: StateFlow>> = latestLibraryContentState.asStateFlow() init { - scope.launch { - ensureReady() - } + ensureReady() } @OptIn(ExperimentalAtomicApi::class) @@ -83,17 +83,23 @@ class InMemoryAppContentRepository @Inject constructor( return } Timber.tag(TAG).d("Initializing home repository") - scope.launch { loadHomeCache() } - scope.launch { refreshHomeData() } + val loadJob = scope.launch { loadHomeCache() } + cacheLoadJob = loadJob + scope.launch { + loadJob.join() + refreshHomeData() + } } override suspend fun refreshHomeData() { + cacheLoadJob?.join() val job = synchronized(this) { refreshJob?.takeIf { it.isActive } ?: scope.launch { - runCatching { + val snapshot = snapshotHomeContent() + try { Timber.tag(TAG).d("Refreshing home data") if (!networkMonitor.isOnline.first()) { - return@runCatching + return@launch } loadLibraries() loadSuggestions() @@ -102,8 +108,15 @@ class InMemoryAppContentRepository @Inject constructor( loadLatestLibraryContent() Timber.tag(TAG).d("Home refresh successful") persistHomeCache() - }.onFailure { error -> + } catch (error: CancellationException) { + throw error + } catch (error: HomeRefreshFailedException) { + restoreHomeContent(snapshot) + Timber.tag(TAG).w(error.cause, "Home refresh failed; keeping cached content") + } catch (error: Exception) { + restoreHomeContent(snapshot) Timber.tag(TAG).w(error, "Home refresh failed; keeping cached content") + networkMonitor.checkConnection() } }.also { refreshJob = it } } @@ -200,8 +213,7 @@ class InMemoryAppContentRepository @Inject constructor( private suspend fun loadLibraries() { val librariesItem = runCatching { jellyfinApiClient.getLibraries() } .getOrElse { error -> - Timber.tag(TAG).w(error, "Unable to load libraries") - return + handleRefreshFailure(error, "Unable to load libraries") } val filteredLibraries = librariesItem.filter { it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS @@ -222,8 +234,7 @@ class InMemoryAppContentRepository @Inject constructor( private suspend fun loadLibrary(library: Library): Library { val contentItem = runCatching { jellyfinApiClient.getLibraryContent(library.id) } .getOrElse { error -> - Timber.tag(TAG).w(error, "Unable to load library ${library.id}") - return library + handleRefreshFailure(error, "Unable to load library ${library.id}") } return when (library.type) { LibraryKind.MOVIES -> library.copy( @@ -240,8 +251,7 @@ class InMemoryAppContentRepository @Inject constructor( private suspend fun loadSuggestions() { val suggestionsItems = runCatching { jellyfinApiClient.getSuggestions() } .getOrElse { error -> - Timber.tag(TAG).w(error, "Unable to load suggestions") - return + handleRefreshFailure(error, "Unable to load suggestions") } suggestionsState.value = suggestionsItems.mapNotNull { item -> when (item.type) { @@ -261,8 +271,7 @@ class InMemoryAppContentRepository @Inject constructor( private suspend fun loadContinueWatching() { val continueWatchingItems = runCatching { jellyfinApiClient.getContinueWatching() } .getOrElse { error -> - Timber.tag(TAG).w(error, "Unable to load continue watching") - return + handleRefreshFailure(error, "Unable to load continue watching") } continueWatchingState.value = continueWatchingItems.mapNotNull { item -> when (item.type) { @@ -282,8 +291,7 @@ class InMemoryAppContentRepository @Inject constructor( private suspend fun loadNextUp() { val nextUpItems = runCatching { jellyfinApiClient.getNextUpEpisodes() } .getOrElse { error -> - Timber.tag(TAG).w(error, "Unable to load next up") - return + handleRefreshFailure(error, "Unable to load next up") } nextUpState.value = nextUpItems.map { item -> Media.EpisodeMedia(episodeId = item.id, seriesId = item.seriesId!!) @@ -297,8 +305,7 @@ class InMemoryAppContentRepository @Inject constructor( private suspend fun loadLatestLibraryContent() { val librariesItem = runCatching { jellyfinApiClient.getLibraries() } .getOrElse { error -> - Timber.tag(TAG).w(error, "Unable to load latest library content") - return + handleRefreshFailure(error, "Unable to load latest library content") } val filteredLibraries = librariesItem.filter { it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS @@ -306,8 +313,7 @@ class InMemoryAppContentRepository @Inject constructor( val latestLibraryContents = filteredLibraries.associate { library -> val latestFromLibrary = runCatching { jellyfinApiClient.getLatestFromLibrary(library.id) } .getOrElse { error -> - Timber.tag(TAG).w(error, "Unable to load latest items for library ${library.id}") - emptyList() + handleRefreshFailure(error, "Unable to load latest items for library ${library.id}") } library.id to when (library.collectionType) { CollectionType.MOVIES -> latestFromLibrary.map { @@ -341,6 +347,31 @@ class InMemoryAppContentRepository @Inject constructor( return userSessionRepository.serverUrl.first() } + private fun snapshotHomeContent(): HomeContentSnapshot = HomeContentSnapshot( + libraries = librariesState.value, + suggestions = suggestionsState.value, + continueWatching = continueWatchingState.value, + nextUp = nextUpState.value, + latestLibraryContent = latestLibraryContentState.value, + ) + + private fun restoreHomeContent(snapshot: HomeContentSnapshot) { + librariesState.value = snapshot.libraries + suggestionsState.value = snapshot.suggestions + continueWatchingState.value = snapshot.continueWatching + nextUpState.value = snapshot.nextUp + latestLibraryContentState.value = snapshot.latestLibraryContent + } + + private suspend fun handleRefreshFailure(error: Throwable, message: String): Nothing { + if (error is CancellationException) { + throw error + } + Timber.tag(TAG).w(error, message) + networkMonitor.checkConnection() + throw HomeRefreshFailedException(error) + } + companion object { private const val TAG = "InMemoryAppContentRepo" } @@ -351,3 +382,13 @@ private data class ReferencedHomeMediaIds( val seriesIds: Set, val episodeIds: Set, ) + +private data class HomeContentSnapshot( + val libraries: List, + val suggestions: List, + val continueWatching: List, + val nextUp: List, + val latestLibraryContent: Map>, +) + +private class HomeRefreshFailedException(cause: Throwable) : RuntimeException(cause) \ No newline at end of file diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/ConnectivityNetworkMonitor.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/ConnectivityNetworkMonitor.kt index 5a954123..4f323e6e 100644 --- a/data/src/main/java/hu/bbara/purefin/data/jellyfin/ConnectivityNetworkMonitor.kt +++ b/data/src/main/java/hu/bbara/purefin/data/jellyfin/ConnectivityNetworkMonitor.kt @@ -7,28 +7,48 @@ import android.net.NetworkCapabilities import android.net.NetworkRequest import dagger.hilt.android.qualifiers.ApplicationContext import hu.bbara.purefin.core.data.NetworkMonitor -import kotlinx.coroutines.channels.awaitClose +import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @Singleton class ConnectivityNetworkMonitor @Inject constructor( @ApplicationContext private val context: Context, + private val jellyfinApiClient: JellyfinApiClient, ) : NetworkMonitor { - override val isOnline: Flow = callbackFlow { - val connectivityManager = context.getSystemService(ConnectivityManager::class.java) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val connectivityManager = context.getSystemService(ConnectivityManager::class.java) + private val isAndroidConnected = MutableStateFlow(connectivityManager.isCurrentlyConnected()) + private val isServerReachable = MutableStateFlow(true) + override val isOnline: Flow = combine( + isAndroidConnected, + isServerReachable, + ) { androidConnected, serverReachable -> + androidConnected && serverReachable + }.distinctUntilChanged() + + init { val callback = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { - trySend(true) + isAndroidConnected.value = true + scope.launch { updateServerReachability() } } override fun onLost(network: Network) { - trySend(connectivityManager.isCurrentlyConnected()) + isAndroidConnected.value = connectivityManager.isCurrentlyConnected() } } @@ -36,14 +56,42 @@ class ConnectivityNetworkMonitor @Inject constructor( .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) .build() - trySend(connectivityManager.isCurrentlyConnected()) connectivityManager.registerNetworkCallback(request, callback) - awaitClose { connectivityManager.unregisterNetworkCallback(callback) } - }.distinctUntilChanged() + } + + override suspend fun checkConnection(): Boolean { + val androidConnected = connectivityManager.isCurrentlyConnected() + isAndroidConnected.value = androidConnected + if (!androidConnected) { + isServerReachable.value = false + return false + } + return updateServerReachability() + } + + private suspend fun updateServerReachability(): Boolean { + val reachable = try { + withTimeoutOrNull(SERVER_CHECK_TIMEOUT_MS) { + jellyfinApiClient.probeServer() + } ?: false + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + Timber.tag(TAG).w(error, "Jellyfin server reachability check failed") + false + } + isServerReachable.value = reachable + return reachable + } private fun ConnectivityManager.isCurrentlyConnected(): Boolean { val network = activeNetwork ?: return false val caps = getNetworkCapabilities(network) ?: return false return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } + + private companion object { + private const val TAG = "ConnectivityNetworkMonitor" + private const val SERVER_CHECK_TIMEOUT_MS = 5_000L + } } diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt index ed8ce5e8..7a786002 100644 --- a/data/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt +++ b/data/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt @@ -67,18 +67,31 @@ class DefaultPlayableMediaRepository @Inject constructor( return getOfflineDownloadedPlayableMedia(mediaId, downloadedMediaItem) } - return runCatching { + val onlinePlayableMedia = runCatching { getOnlinePlayableMedia(mediaId, downloadedMediaItem) - ?: getOfflineDownloadedPlayableMedia(mediaId, downloadedMediaItem) }.getOrElse { error -> if (error is CancellationException) throw error + networkMonitor.checkConnection() Timber.tag(TAG).w(error, "Unable to load online metadata for downloaded media $mediaId") - getOfflineDownloadedPlayableMedia(mediaId, downloadedMediaItem) + null } + if (onlinePlayableMedia != null) return onlinePlayableMedia + + networkMonitor.checkConnection() + return getOfflineDownloadedPlayableMedia(mediaId, downloadedMediaItem) } private suspend fun getStreamingPlayableMedia(mediaId: UUID): PlayableMedia? { - return getOnlinePlayableMedia(mediaId, downloadedMediaItem = null) + if (!networkMonitor.isOnline.first()) return null + + return runCatching { + getOnlinePlayableMedia(mediaId, downloadedMediaItem = null) + }.getOrElse { error -> + if (error is CancellationException) throw error + networkMonitor.checkConnection() + Timber.tag(TAG).w(error, "Unable to load streaming media $mediaId") + null + } } private suspend fun getOnlinePlayableMedia( diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt index 8ddae710..c4a42bdb 100644 --- a/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt +++ b/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt @@ -26,6 +26,7 @@ import org.jellyfin.sdk.api.client.extensions.mediaSegmentsApi import org.jellyfin.sdk.api.client.extensions.playStateApi import org.jellyfin.sdk.api.client.extensions.quickConnectApi import org.jellyfin.sdk.api.client.extensions.suggestionsApi +import org.jellyfin.sdk.api.client.extensions.systemApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi @@ -227,6 +228,16 @@ class JellyfinApiClient @Inject constructor( } } + suspend fun probeServer(): Boolean = withContext(Dispatchers.IO) { + logRequest("probeServer") { + if (!ensureConfigured()) { + return@logRequest false + } + api.systemApi.getPingSystem() + true + } + } + suspend fun getLibraryContent(libraryId: UUID): List = withContext(Dispatchers.IO) { logRequest("getLibraryContent") { if (!ensureConfigured()) {