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"?>
<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_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

View File

@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<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_DATA_SYNC" />
<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,
private val homeCacheDataStore: DataStore<HomeCache>,
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()
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

View File

@@ -36,7 +36,11 @@ class InMemoryMediaRepository @Inject constructor(
) : MediaRepository {
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())
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 {
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) {