feat(connectivity): add offline resilience with server reachability checks

This commit is contained in:
2026-06-07 11:25:59 +00:00
parent 3f9fd6e36e
commit 4a6977eefb
11 changed files with 317 additions and 54 deletions

View File

@@ -13,11 +13,11 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.entryProvider
@@ -46,6 +46,7 @@ fun AppScreen(
val nextUp by viewModel.nextUp.collectAsStateWithLifecycle() val nextUp by viewModel.nextUp.collectAsStateWithLifecycle()
val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle() val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle()
val isOnline by viewModel.isOnline.collectAsStateWithLifecycle() val isOnline by viewModel.isOnline.collectAsStateWithLifecycle()
val isCheckingConnection by viewModel.isCheckingConnection.collectAsStateWithLifecycle()
val isCheckingForUpdates by updateViewModel.isCheckingForUpdates.collectAsStateWithLifecycle() val isCheckingForUpdates by updateViewModel.isCheckingForUpdates.collectAsStateWithLifecycle()
val availableUpdate by updateViewModel.availableUpdate.collectAsStateWithLifecycle() val availableUpdate by updateViewModel.availableUpdate.collectAsStateWithLifecycle()
val navigationManager = LocalNavigationManager.current val navigationManager = LocalNavigationManager.current
@@ -133,6 +134,8 @@ fun AppScreen(
DownloadsScreen( DownloadsScreen(
selectedTab = selectedTab, selectedTab = selectedTab,
isOnline = isOnline, isOnline = isOnline,
isCheckingConnection = isCheckingConnection,
onCheckConnection = viewModel::checkConnection,
onTabSelected = onTabSelected, onTabSelected = onTabSelected,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )

View File

@@ -10,12 +10,15 @@ import androidx.compose.ui.Modifier
import hu.bbara.purefin.ui.screen.AppBottomBar import hu.bbara.purefin.ui.screen.AppBottomBar
import hu.bbara.purefin.ui.screen.download.components.DownloadsContent 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.DefaultTopBar
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBarTextButton
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun DownloadsScreen( fun DownloadsScreen(
selectedTab: Int, selectedTab: Int,
isOnline: Boolean, isOnline: Boolean,
isCheckingConnection: Boolean,
onCheckConnection: () -> Unit,
onTabSelected: (Int) -> Unit, onTabSelected: (Int) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@@ -23,7 +26,19 @@ fun DownloadsScreen(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
containerColor = MaterialTheme.colorScheme.background, containerColor = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground, contentColor = MaterialTheme.colorScheme.onBackground,
topBar = { DefaultTopBar() }, topBar = {
DefaultTopBar(
rightActions = {
if (!isOnline) {
DefaultTopBarTextButton(
text = if (isCheckingConnection) "Checking…" else "Check connection",
onClick = onCheckConnection,
enabled = !isCheckingConnection
)
}
}
)
},
bottomBar = { bottomBar = {
AppBottomBar( AppBottomBar(
selectedTab = selectedTab, selectedTab = selectedTab,

View File

@@ -5,14 +5,17 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Download import androidx.compose.material.icons.outlined.Download
import androidx.compose.material3.Card
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text

View File

@@ -5,18 +5,23 @@ import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonColors import androidx.compose.material3.IconButtonColors
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior 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) @OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
fun DefaultTopBarSearchButton( fun DefaultTopBarSearchButton(

View File

@@ -9,12 +9,13 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@@ -25,12 +26,13 @@ import javax.inject.Singleton
class CompositeLocalMediaRepository @Inject constructor( class CompositeLocalMediaRepository @Inject constructor(
@Offline private val offlineRepository: LocalMediaRepository, @Offline private val offlineRepository: LocalMediaRepository,
@Online private val onlineRepository: LocalMediaRepository, @Online private val onlineRepository: LocalMediaRepository,
private val networkMonitor: NetworkMonitor,
) : LocalMediaRepository { ) : LocalMediaRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) 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<LocalMediaRepository> = networkMonitor.isOnline
private val activeRepository: Flow<LocalMediaRepository> = flowOf(onlineRepository) .map { isOnline -> if (isOnline) onlineRepository else offlineRepository }
override val movies: StateFlow<Map<UUID, Movie>> = activeRepository override val movies: StateFlow<Map<UUID, Movie>> = activeRepository
.flatMapLatest { it.movies } .flatMapLatest { it.movies }
@@ -45,40 +47,111 @@ class CompositeLocalMediaRepository @Inject constructor(
.stateIn(scope, Eagerly, emptyMap()) .stateIn(scope, Eagerly, emptyMap())
override suspend fun getMovie(id: UUID): Flow<Movie?> { override suspend fun getMovie(id: UUID): Flow<Movie?> {
return activeRepository return getFromActiveRepository(
.flatMapLatest { it.getMovie(id) } onlineRead = { onlineRepository.getMovie(id) },
offlineRead = { offlineRepository.getMovie(id) },
)
} }
override suspend fun getSeries(id: UUID): Flow<Series?> { override suspend fun getSeries(id: UUID): Flow<Series?> {
return activeRepository return getFromActiveRepository(
.flatMapLatest { it.getSeries(id) } onlineRead = { onlineRepository.getSeries(id) },
offlineRead = { offlineRepository.getSeries(id) },
)
} }
override suspend fun getEpisode(id: UUID): Flow<Episode?> { override suspend fun getEpisode(id: UUID): Flow<Episode?> {
return activeRepository return getFromActiveRepository(
.flatMapLatest { it.getEpisode(id) } onlineRead = { onlineRepository.getEpisode(id) },
offlineRead = { offlineRepository.getEpisode(id) },
)
} }
override suspend fun loadSeasons(seriesId: UUID) { 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) { 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) { 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) offlineRepository.updateWatchProgress(mediaId, positionMs, durationMs)
} }
override suspend fun updateWatchProgressPercent(mediaId: UUID, progressPercent: Double) { 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) offlineRepository.updateWatchProgressPercent(mediaId, progressPercent)
} }
override suspend fun markAsWatched(mediaId: UUID, watched: Boolean) { override suspend fun markAsWatched(mediaId: UUID, watched: Boolean) {
val repository = onlineRepository runOnlineOrOfflineNoOp(
repository.markAsWatched(mediaId, watched) onlineAction = { onlineRepository.markAsWatched(mediaId, watched) },
offlineAction = { offlineRepository.markAsWatched(mediaId, watched) },
)
}
private suspend fun <T> getFromActiveRepository(
onlineRead: suspend () -> Flow<T>,
offlineRead: suspend () -> Flow<T>,
): Flow<T> {
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
}
}
} }
} }

View File

@@ -4,4 +4,5 @@ import kotlinx.coroutines.flow.Flow
interface NetworkMonitor { interface NetworkMonitor {
val isOnline: Flow<Boolean> val isOnline: Flow<Boolean>
suspend fun checkConnection(): Boolean
} }

View File

@@ -43,17 +43,20 @@ class AppViewModel @Inject constructor(
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val homeRefreshCoordinator: HomeRefreshCoordinator, private val homeRefreshCoordinator: HomeRefreshCoordinator,
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
) : ViewModel() { ) : ViewModel() {
private val _isRefreshing = MutableStateFlow(false) private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow() val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
private val _isCheckingConnection = MutableStateFlow(false)
val isCheckingConnection: StateFlow<Boolean> = _isCheckingConnection.asStateFlow()
val isOnline: StateFlow<Boolean> = networkMonitor.isOnline val isOnline: StateFlow<Boolean> = networkMonitor.isOnline
.stateIn( .stateIn(
viewModelScope, viewModelScope,
SharingStarted.Eagerly, SharingStarted.Eagerly,
false true
) )
val serverUrl: StateFlow<String> = userSessionRepository.serverUrl val serverUrl: StateFlow<String> = userSessionRepository.serverUrl
@@ -253,18 +256,34 @@ class AppViewModel @Inject constructor(
fun onResumed() { fun onResumed() {
viewModelScope.launch { viewModelScope.launch {
networkMonitor.checkConnection()
homeRefreshCoordinator.onResumed() homeRefreshCoordinator.onResumed()
} }
} }
fun onRefresh() { fun onRefresh() {
viewModelScope.launch { viewModelScope.launch {
networkMonitor.checkConnection()
homeRefreshCoordinator.onRefresh { isRefreshing -> homeRefreshCoordinator.onRefresh { isRefreshing ->
_isRefreshing.value = 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() { fun openSearch() {
navigationManager.navigate(Route.HomeSearchRoute) navigationManager.navigate(Route.HomeSearchRoute)
} }

View File

@@ -24,6 +24,7 @@ import hu.bbara.purefin.data.offline.cache.toSeries
import hu.bbara.purefin.model.Library import hu.bbara.purefin.model.Library
import hu.bbara.purefin.model.LibraryKind import hu.bbara.purefin.model.LibraryKind
import hu.bbara.purefin.model.Media import hu.bbara.purefin.model.Media
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -51,6 +52,7 @@ class InMemoryAppContentRepository @Inject constructor(
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
) : HomeRepository { ) : HomeRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var cacheLoadJob: Job? = null
private var refreshJob: Job? = null private var refreshJob: Job? = null
@OptIn(ExperimentalAtomicApi::class) @OptIn(ExperimentalAtomicApi::class)
@@ -72,9 +74,7 @@ class InMemoryAppContentRepository @Inject constructor(
override val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> = latestLibraryContentState.asStateFlow() override val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> = latestLibraryContentState.asStateFlow()
init { init {
scope.launch { ensureReady()
ensureReady()
}
} }
@OptIn(ExperimentalAtomicApi::class) @OptIn(ExperimentalAtomicApi::class)
@@ -83,17 +83,23 @@ class InMemoryAppContentRepository @Inject constructor(
return return
} }
Timber.tag(TAG).d("Initializing home repository") Timber.tag(TAG).d("Initializing home repository")
scope.launch { loadHomeCache() } val loadJob = scope.launch { loadHomeCache() }
scope.launch { refreshHomeData() } cacheLoadJob = loadJob
scope.launch {
loadJob.join()
refreshHomeData()
}
} }
override suspend fun refreshHomeData() { override suspend fun refreshHomeData() {
cacheLoadJob?.join()
val job = synchronized(this) { val job = synchronized(this) {
refreshJob?.takeIf { it.isActive } ?: scope.launch { refreshJob?.takeIf { it.isActive } ?: scope.launch {
runCatching { val snapshot = snapshotHomeContent()
try {
Timber.tag(TAG).d("Refreshing home data") Timber.tag(TAG).d("Refreshing home data")
if (!networkMonitor.isOnline.first()) { if (!networkMonitor.isOnline.first()) {
return@runCatching return@launch
} }
loadLibraries() loadLibraries()
loadSuggestions() loadSuggestions()
@@ -102,8 +108,15 @@ class InMemoryAppContentRepository @Inject constructor(
loadLatestLibraryContent() loadLatestLibraryContent()
Timber.tag(TAG).d("Home refresh successful") Timber.tag(TAG).d("Home refresh successful")
persistHomeCache() 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") Timber.tag(TAG).w(error, "Home refresh failed; keeping cached content")
networkMonitor.checkConnection()
} }
}.also { refreshJob = it } }.also { refreshJob = it }
} }
@@ -200,8 +213,7 @@ class InMemoryAppContentRepository @Inject constructor(
private suspend fun loadLibraries() { private suspend fun loadLibraries() {
val librariesItem = runCatching { jellyfinApiClient.getLibraries() } val librariesItem = runCatching { jellyfinApiClient.getLibraries() }
.getOrElse { error -> .getOrElse { error ->
Timber.tag(TAG).w(error, "Unable to load libraries") handleRefreshFailure(error, "Unable to load libraries")
return
} }
val filteredLibraries = librariesItem.filter { val filteredLibraries = librariesItem.filter {
it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS
@@ -222,8 +234,7 @@ class InMemoryAppContentRepository @Inject constructor(
private suspend fun loadLibrary(library: Library): Library { private suspend fun loadLibrary(library: Library): Library {
val contentItem = runCatching { jellyfinApiClient.getLibraryContent(library.id) } val contentItem = runCatching { jellyfinApiClient.getLibraryContent(library.id) }
.getOrElse { error -> .getOrElse { error ->
Timber.tag(TAG).w(error, "Unable to load library ${library.id}") handleRefreshFailure(error, "Unable to load library ${library.id}")
return library
} }
return when (library.type) { return when (library.type) {
LibraryKind.MOVIES -> library.copy( LibraryKind.MOVIES -> library.copy(
@@ -240,8 +251,7 @@ class InMemoryAppContentRepository @Inject constructor(
private suspend fun loadSuggestions() { private suspend fun loadSuggestions() {
val suggestionsItems = runCatching { jellyfinApiClient.getSuggestions() } val suggestionsItems = runCatching { jellyfinApiClient.getSuggestions() }
.getOrElse { error -> .getOrElse { error ->
Timber.tag(TAG).w(error, "Unable to load suggestions") handleRefreshFailure(error, "Unable to load suggestions")
return
} }
suggestionsState.value = suggestionsItems.mapNotNull { item -> suggestionsState.value = suggestionsItems.mapNotNull { item ->
when (item.type) { when (item.type) {
@@ -261,8 +271,7 @@ class InMemoryAppContentRepository @Inject constructor(
private suspend fun loadContinueWatching() { private suspend fun loadContinueWatching() {
val continueWatchingItems = runCatching { jellyfinApiClient.getContinueWatching() } val continueWatchingItems = runCatching { jellyfinApiClient.getContinueWatching() }
.getOrElse { error -> .getOrElse { error ->
Timber.tag(TAG).w(error, "Unable to load continue watching") handleRefreshFailure(error, "Unable to load continue watching")
return
} }
continueWatchingState.value = continueWatchingItems.mapNotNull { item -> continueWatchingState.value = continueWatchingItems.mapNotNull { item ->
when (item.type) { when (item.type) {
@@ -282,8 +291,7 @@ class InMemoryAppContentRepository @Inject constructor(
private suspend fun loadNextUp() { private suspend fun loadNextUp() {
val nextUpItems = runCatching { jellyfinApiClient.getNextUpEpisodes() } val nextUpItems = runCatching { jellyfinApiClient.getNextUpEpisodes() }
.getOrElse { error -> .getOrElse { error ->
Timber.tag(TAG).w(error, "Unable to load next up") handleRefreshFailure(error, "Unable to load next up")
return
} }
nextUpState.value = nextUpItems.map { item -> nextUpState.value = nextUpItems.map { item ->
Media.EpisodeMedia(episodeId = item.id, seriesId = item.seriesId!!) Media.EpisodeMedia(episodeId = item.id, seriesId = item.seriesId!!)
@@ -297,8 +305,7 @@ class InMemoryAppContentRepository @Inject constructor(
private suspend fun loadLatestLibraryContent() { private suspend fun loadLatestLibraryContent() {
val librariesItem = runCatching { jellyfinApiClient.getLibraries() } val librariesItem = runCatching { jellyfinApiClient.getLibraries() }
.getOrElse { error -> .getOrElse { error ->
Timber.tag(TAG).w(error, "Unable to load latest library content") handleRefreshFailure(error, "Unable to load latest library content")
return
} }
val filteredLibraries = librariesItem.filter { val filteredLibraries = librariesItem.filter {
it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS
@@ -306,8 +313,7 @@ class InMemoryAppContentRepository @Inject constructor(
val latestLibraryContents = filteredLibraries.associate { library -> val latestLibraryContents = filteredLibraries.associate { library ->
val latestFromLibrary = runCatching { jellyfinApiClient.getLatestFromLibrary(library.id) } val latestFromLibrary = runCatching { jellyfinApiClient.getLatestFromLibrary(library.id) }
.getOrElse { error -> .getOrElse { error ->
Timber.tag(TAG).w(error, "Unable to load latest items for library ${library.id}") handleRefreshFailure(error, "Unable to load latest items for library ${library.id}")
emptyList()
} }
library.id to when (library.collectionType) { library.id to when (library.collectionType) {
CollectionType.MOVIES -> latestFromLibrary.map { CollectionType.MOVIES -> latestFromLibrary.map {
@@ -341,6 +347,31 @@ class InMemoryAppContentRepository @Inject constructor(
return userSessionRepository.serverUrl.first() 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 { companion object {
private const val TAG = "InMemoryAppContentRepo" private const val TAG = "InMemoryAppContentRepo"
} }
@@ -351,3 +382,13 @@ private data class ReferencedHomeMediaIds(
val seriesIds: Set<UUID>, val seriesIds: Set<UUID>,
val episodeIds: Set<UUID>, val episodeIds: Set<UUID>,
) )
private data class HomeContentSnapshot(
val libraries: List<Library>,
val suggestions: List<Media>,
val continueWatching: List<Media>,
val nextUp: List<Media>,
val latestLibraryContent: Map<UUID, List<Media>>,
)
private class HomeRefreshFailedException(cause: Throwable) : RuntimeException(cause)

View File

@@ -7,28 +7,48 @@ import android.net.NetworkCapabilities
import android.net.NetworkRequest import android.net.NetworkRequest
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import hu.bbara.purefin.core.data.NetworkMonitor 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.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class ConnectivityNetworkMonitor @Inject constructor( class ConnectivityNetworkMonitor @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val jellyfinApiClient: JellyfinApiClient,
) : NetworkMonitor { ) : NetworkMonitor {
override val isOnline: Flow<Boolean> = callbackFlow { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val connectivityManager = context.getSystemService(ConnectivityManager::class.java) private val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
private val isAndroidConnected = MutableStateFlow(connectivityManager.isCurrentlyConnected())
private val isServerReachable = MutableStateFlow(true)
override val isOnline: Flow<Boolean> = combine(
isAndroidConnected,
isServerReachable,
) { androidConnected, serverReachable ->
androidConnected && serverReachable
}.distinctUntilChanged()
init {
val callback = object : ConnectivityManager.NetworkCallback() { val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { override fun onAvailable(network: Network) {
trySend(true) isAndroidConnected.value = true
scope.launch { updateServerReachability() }
} }
override fun onLost(network: Network) { 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) .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build() .build()
trySend(connectivityManager.isCurrentlyConnected())
connectivityManager.registerNetworkCallback(request, callback) 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 { private fun ConnectivityManager.isCurrentlyConnected(): Boolean {
val network = activeNetwork ?: return false val network = activeNetwork ?: return false
val caps = getNetworkCapabilities(network) ?: return false val caps = getNetworkCapabilities(network) ?: return false
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
} }
private companion object {
private const val TAG = "ConnectivityNetworkMonitor"
private const val SERVER_CHECK_TIMEOUT_MS = 5_000L
}
} }

View File

@@ -67,18 +67,31 @@ class DefaultPlayableMediaRepository @Inject constructor(
return getOfflineDownloadedPlayableMedia(mediaId, downloadedMediaItem) return getOfflineDownloadedPlayableMedia(mediaId, downloadedMediaItem)
} }
return runCatching { val onlinePlayableMedia = runCatching {
getOnlinePlayableMedia(mediaId, downloadedMediaItem) getOnlinePlayableMedia(mediaId, downloadedMediaItem)
?: getOfflineDownloadedPlayableMedia(mediaId, downloadedMediaItem)
}.getOrElse { error -> }.getOrElse { error ->
if (error is CancellationException) throw error if (error is CancellationException) throw error
networkMonitor.checkConnection()
Timber.tag(TAG).w(error, "Unable to load online metadata for downloaded media $mediaId") 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? { 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( private suspend fun getOnlinePlayableMedia(

View File

@@ -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.playStateApi
import org.jellyfin.sdk.api.client.extensions.quickConnectApi import org.jellyfin.sdk.api.client.extensions.quickConnectApi
import org.jellyfin.sdk.api.client.extensions.suggestionsApi 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.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi 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<BaseItemDto> = withContext(Dispatchers.IO) { suspend fun getLibraryContent(libraryId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
logRequest("getLibraryContent") { logRequest("getLibraryContent") {
if (!ensureConfigured()) { if (!ensureConfigured()) {