mirror of
https://github.com/bbara04/Purefin.git
synced 2026-03-31 17:10:08 +02:00
feat: Allow offline mode in Purefin
This commit is contained in:
@@ -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" />
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package hu.bbara.purefin.core.data
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
interface NetworkMonitor {
|
||||||
|
val isOnline: Flow<Boolean>
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user