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

@@ -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<Map<UUID, List<Media>>> = 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<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 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<Boolean> = 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<Boolean> = 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
}
}

View File

@@ -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(

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.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<BaseItemDto> = withContext(Dispatchers.IO) {
logRequest("getLibraryContent") {
if (!ensureConfigured()) {