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

@@ -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<LocalMediaRepository> = flowOf(onlineRepository)
private val activeRepository: Flow<LocalMediaRepository> = networkMonitor.isOnline
.map { isOnline -> if (isOnline) onlineRepository else offlineRepository }
override val movies: StateFlow<Map<UUID, Movie>> = activeRepository
.flatMapLatest { it.movies }
@@ -45,40 +47,111 @@ class CompositeLocalMediaRepository @Inject constructor(
.stateIn(scope, Eagerly, emptyMap())
override suspend fun getMovie(id: UUID): Flow<Movie?> {
return activeRepository
.flatMapLatest { it.getMovie(id) }
return getFromActiveRepository(
onlineRead = { onlineRepository.getMovie(id) },
offlineRead = { offlineRepository.getMovie(id) },
)
}
override suspend fun getSeries(id: UUID): Flow<Series?> {
return activeRepository
.flatMapLatest { it.getSeries(id) }
return getFromActiveRepository(
onlineRead = { onlineRepository.getSeries(id) },
offlineRead = { offlineRepository.getSeries(id) },
)
}
override suspend fun getEpisode(id: UUID): Flow<Episode?> {
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 <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 {
val isOnline: Flow<Boolean>
suspend fun checkConnection(): Boolean
}

View File

@@ -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<Boolean> = _isRefreshing.asStateFlow()
private val _isCheckingConnection = MutableStateFlow(false)
val isCheckingConnection: StateFlow<Boolean> = _isCheckingConnection.asStateFlow()
val isOnline: StateFlow<Boolean> = networkMonitor.isOnline
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
false
true
)
val serverUrl: StateFlow<String> = 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)
}