feat: Allow offline mode in Purefin

This commit is contained in:
2026-02-22 15:41:08 +01:00
parent 4ecb402e69
commit 9ec09a0e94
8 changed files with 121 additions and 6 deletions

View File

@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

View File

@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

View File

@@ -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<Boolean> = 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)
}
}

View File

@@ -41,6 +41,7 @@ class InMemoryAppContentRepository @Inject constructor(
val jellyfinApiClient: JellyfinApiClient, val jellyfinApiClient: JellyfinApiClient,
private val homeCacheDataStore: DataStore<HomeCache>, private val homeCacheDataStore: DataStore<HomeCache>,
private val mediaRepository: InMemoryMediaRepository, private val mediaRepository: InMemoryMediaRepository,
private val networkMonitor: NetworkMonitor,
) : AppContentRepository, MediaRepository by mediaRepository { ) : AppContentRepository, MediaRepository by mediaRepository {
private val readyMutex = Mutex() private val readyMutex = Mutex()
@@ -65,7 +66,23 @@ class InMemoryAppContentRepository @Inject constructor(
init { init {
scope.launch { scope.launch {
loadFromCache() 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() { 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 val ready = mediaRepository.ready
if (ready.isCompleted) { if (ready.isCompleted) {
ready.await() ready.await()
@@ -125,8 +154,8 @@ class InMemoryAppContentRepository @Inject constructor(
// Only the first caller runs the loading logic; others wait on the deferred. // Only the first caller runs the loading logic; others wait on the deferred.
if (readyMutex.tryLock()) { if (readyMutex.tryLock()) {
try { try {
if (ready.isCompleted) { if (mediaRepository.ready.isCompleted) {
ready.await() mediaRepository.ready.await()
return return
} }
loadLibraries() loadLibraries()
@@ -145,7 +174,7 @@ class InMemoryAppContentRepository @Inject constructor(
readyMutex.unlock() readyMutex.unlock()
} }
} else { } else {
ready.await() mediaRepository.ready.await()
} }
} }
@@ -288,6 +317,9 @@ class InMemoryAppContentRepository @Inject constructor(
} }
override suspend fun refreshHomeData() { override suspend fun refreshHomeData() {
val isOffline = userSessionRepository.isOfflineMode.first()
if (isOffline) return
mediaRepository.ready.await() mediaRepository.ready.await()
// Skip refresh if the initial load (or last refresh) just happened // Skip refresh if the initial load (or last refresh) just happened
val elapsed = System.currentTimeMillis() - initialLoadTimestamp val elapsed = System.currentTimeMillis() - initialLoadTimestamp

View File

@@ -36,7 +36,11 @@ class InMemoryMediaRepository @Inject constructor(
) : MediaRepository { ) : MediaRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
internal val ready = CompletableDeferred<Unit>() @Volatile internal var ready = CompletableDeferred<Unit>()
internal fun reset() {
ready = CompletableDeferred()
}
internal val _movies: MutableStateFlow<Map<UUID, Movie>> = MutableStateFlow(emptyMap()) internal val _movies: MutableStateFlow<Map<UUID, Movie>> = MutableStateFlow(emptyMap())
override val movies: StateFlow<Map<UUID, Movie>> = _movies.asStateFlow() override val movies: StateFlow<Map<UUID, Movie>> = _movies.asStateFlow()

View File

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

View File

@@ -0,0 +1,7 @@
package hu.bbara.purefin.core.data
import kotlinx.coroutines.flow.Flow
interface NetworkMonitor {
val isOnline: Flow<Boolean>
}

View File

@@ -131,7 +131,13 @@ class HomePageViewModel @Inject constructor(
) )
init { 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) { fun onLibrarySelected(id: UUID, name: String) {