From 9ec09a0e94ed43ed4f1fa9e8d66ed498997f3ab7 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Sun, 22 Feb 2026 15:41:08 +0100 Subject: [PATCH] feat: Allow offline mode in Purefin --- app-tv/src/main/AndroidManifest.xml | 1 + app/src/main/AndroidManifest.xml | 1 + .../core/data/ConnectivityNetworkMonitor.kt | 48 +++++++++++++++++++ .../core/data/InMemoryAppContentRepository.kt | 40 ++++++++++++++-- .../core/data/InMemoryMediaRepository.kt | 6 ++- .../bbara/purefin/core/data/NetworkModule.kt | 16 +++++++ .../bbara/purefin/core/data/NetworkMonitor.kt | 7 +++ .../feature/shared/home/HomePageViewModel.kt | 8 +++- 8 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 core/data/src/main/java/hu/bbara/purefin/core/data/ConnectivityNetworkMonitor.kt create mode 100644 core/data/src/main/java/hu/bbara/purefin/core/data/NetworkModule.kt create mode 100644 core/data/src/main/java/hu/bbara/purefin/core/data/NetworkMonitor.kt diff --git a/app-tv/src/main/AndroidManifest.xml b/app-tv/src/main/AndroidManifest.xml index 2887bae..e4ec942 100644 --- a/app-tv/src/main/AndroidManifest.xml +++ b/app-tv/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 82ad2fa..38a548f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/ConnectivityNetworkMonitor.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/ConnectivityNetworkMonitor.kt new file mode 100644 index 0000000..7aaa894 --- /dev/null +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/ConnectivityNetworkMonitor.kt @@ -0,0 +1,48 @@ +package hu.bbara.purefin.core.data + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ConnectivityNetworkMonitor @Inject constructor( + @ApplicationContext private val context: Context, +) : NetworkMonitor { + + override val isOnline: Flow = callbackFlow { + val connectivityManager = context.getSystemService(ConnectivityManager::class.java) + + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + trySend(true) + } + + override fun onLost(network: Network) { + trySend(connectivityManager.isCurrentlyConnected()) + } + } + + val request = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + + trySend(connectivityManager.isCurrentlyConnected()) + connectivityManager.registerNetworkCallback(request, callback) + awaitClose { connectivityManager.unregisterNetworkCallback(callback) } + }.distinctUntilChanged() + + private fun ConnectivityManager.isCurrentlyConnected(): Boolean { + val network = activeNetwork ?: return false + val caps = getNetworkCapabilities(network) ?: return false + return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } +} diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/InMemoryAppContentRepository.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/InMemoryAppContentRepository.kt index 70ae786..c928219 100644 --- a/core/data/src/main/java/hu/bbara/purefin/core/data/InMemoryAppContentRepository.kt +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/InMemoryAppContentRepository.kt @@ -41,6 +41,7 @@ class InMemoryAppContentRepository @Inject constructor( val jellyfinApiClient: JellyfinApiClient, private val homeCacheDataStore: DataStore, private val mediaRepository: InMemoryMediaRepository, + private val networkMonitor: NetworkMonitor, ) : AppContentRepository, MediaRepository by mediaRepository { private val readyMutex = Mutex() @@ -65,7 +66,23 @@ class InMemoryAppContentRepository @Inject constructor( init { scope.launch { loadFromCache() - runCatching { ensureReady() } + networkMonitor.isOnline.collect { isOnline -> + userSessionRepository.setOfflineMode(!isOnline) + if (isOnline) { + if (mediaRepository.ready.isCompleted) { + // Reset so ensureReady() performs a fresh network load + mediaRepository.reset() + _state.value = MediaRepositoryState.Loading + } + runCatching { ensureReady() } + } else { + // Going offline – complete ready with cached data so waiters don't hang + if (!mediaRepository.ready.isCompleted) { + _state.value = MediaRepositoryState.Ready + mediaRepository.signalReady() + } + } + } } } @@ -116,6 +133,18 @@ class InMemoryAppContentRepository @Inject constructor( } override suspend fun ensureReady() { + val isOffline = userSessionRepository.isOfflineMode.first() + if (isOffline) { + // Offline mode: use cached data without network calls + val ready = mediaRepository.ready + if (!ready.isCompleted) { + _state.value = MediaRepositoryState.Ready + mediaRepository.signalReady() + } + mediaRepository.ready.await() + return + } + val ready = mediaRepository.ready if (ready.isCompleted) { ready.await() @@ -125,8 +154,8 @@ class InMemoryAppContentRepository @Inject constructor( // Only the first caller runs the loading logic; others wait on the deferred. if (readyMutex.tryLock()) { try { - if (ready.isCompleted) { - ready.await() + if (mediaRepository.ready.isCompleted) { + mediaRepository.ready.await() return } loadLibraries() @@ -145,7 +174,7 @@ class InMemoryAppContentRepository @Inject constructor( readyMutex.unlock() } } else { - ready.await() + mediaRepository.ready.await() } } @@ -288,6 +317,9 @@ class InMemoryAppContentRepository @Inject constructor( } override suspend fun refreshHomeData() { + val isOffline = userSessionRepository.isOfflineMode.first() + if (isOffline) return + mediaRepository.ready.await() // Skip refresh if the initial load (or last refresh) just happened val elapsed = System.currentTimeMillis() - initialLoadTimestamp diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/InMemoryMediaRepository.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/InMemoryMediaRepository.kt index 9848796..a1f90b7 100644 --- a/core/data/src/main/java/hu/bbara/purefin/core/data/InMemoryMediaRepository.kt +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/InMemoryMediaRepository.kt @@ -36,7 +36,11 @@ class InMemoryMediaRepository @Inject constructor( ) : MediaRepository { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - internal val ready = CompletableDeferred() + @Volatile internal var ready = CompletableDeferred() + + internal fun reset() { + ready = CompletableDeferred() + } internal val _movies: MutableStateFlow> = MutableStateFlow(emptyMap()) override val movies: StateFlow> = _movies.asStateFlow() diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/NetworkModule.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/NetworkModule.kt new file mode 100644 index 0000000..9667e1f --- /dev/null +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/NetworkModule.kt @@ -0,0 +1,16 @@ +package hu.bbara.purefin.core.data + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class NetworkModule { + + @Binds + @Singleton + abstract fun bindNetworkMonitor(impl: ConnectivityNetworkMonitor): NetworkMonitor +} diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/NetworkMonitor.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/NetworkMonitor.kt new file mode 100644 index 0000000..ffa3e9a --- /dev/null +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/NetworkMonitor.kt @@ -0,0 +1,7 @@ +package hu.bbara.purefin.core.data + +import kotlinx.coroutines.flow.Flow + +interface NetworkMonitor { + val isOnline: Flow +} diff --git a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/HomePageViewModel.kt b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/HomePageViewModel.kt index d8ad389..948645f 100644 --- a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/HomePageViewModel.kt +++ b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/HomePageViewModel.kt @@ -131,7 +131,13 @@ class HomePageViewModel @Inject constructor( ) init { - viewModelScope.launch { appContentRepository.ensureReady() } + viewModelScope.launch { + try { + appContentRepository.ensureReady() + } catch (e: Exception) { + // State is already set to Error by ensureReady; don't crash the app + } + } } fun onLibrarySelected(id: UUID, name: String) {