feat(network): enhance connectivity monitoring with request reporting

This commit is contained in:
2026-05-20 17:07:32 +02:00
parent 069f29c1c8
commit 56a2b42434
3 changed files with 80 additions and 9 deletions

View File

@@ -4,4 +4,8 @@ import kotlinx.coroutines.flow.Flow
interface NetworkMonitor { interface NetworkMonitor {
val isOnline: Flow<Boolean> val isOnline: Flow<Boolean>
fun reportRequestSucceeded()
fun reportRequestFailed(error: Throwable)
} }

View File

@@ -7,28 +7,54 @@ import android.net.NetworkCapabilities
import android.net.NetworkRequest import android.net.NetworkRequest
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import hu.bbara.purefin.core.data.NetworkMonitor import hu.bbara.purefin.core.data.NetworkMonitor
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.stateIn
import java.net.ConnectException
import java.net.NoRouteToHostException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class ConnectivityNetworkMonitor @Inject constructor( class ConnectivityNetworkMonitor @Inject constructor(
@ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
) : NetworkMonitor { ) : NetworkMonitor {
override val isOnline: Flow<Boolean> = callbackFlow { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val connectivityManager = context.getSystemService(ConnectivityManager::class.java) private val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
private val isServerReachable = MutableStateFlow(true)
private val isDeviceOnline: Flow<Boolean> = callbackFlow {
var wasDeviceOnline = connectivityManager.isCurrentlyConnected()
val callback = object : ConnectivityManager.NetworkCallback() { val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { override fun onCapabilitiesChanged(
trySend(true) network: Network,
networkCapabilities: NetworkCapabilities
) {
val isCurrentlyConnected = connectivityManager.isCurrentlyConnected()
if (isCurrentlyConnected && !wasDeviceOnline) {
isServerReachable.value = true
}
wasDeviceOnline = isCurrentlyConnected
trySend(isCurrentlyConnected)
} }
override fun onLost(network: Network) { override fun onLost(network: Network) {
trySend(connectivityManager.isCurrentlyConnected()) val isCurrentlyConnected = connectivityManager.isCurrentlyConnected()
wasDeviceOnline = isCurrentlyConnected
trySend(isCurrentlyConnected)
} }
} }
@@ -36,14 +62,51 @@ class ConnectivityNetworkMonitor @Inject constructor(
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build() .build()
trySend(connectivityManager.isCurrentlyConnected()) trySend(wasDeviceOnline)
connectivityManager.registerNetworkCallback(request, callback) connectivityManager.registerNetworkCallback(request, callback)
awaitClose { connectivityManager.unregisterNetworkCallback(callback) } awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
}.distinctUntilChanged() }.distinctUntilChanged()
override val isOnline: StateFlow<Boolean> = combine(
isDeviceOnline,
isServerReachable
) { isDeviceOnline, isServerReachable ->
isDeviceOnline && isServerReachable
}.distinctUntilChanged()
.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = connectivityManager.isCurrentlyConnected() && isServerReachable.value
)
override fun reportRequestSucceeded() {
isServerReachable.value = true
}
override fun reportRequestFailed(error: Throwable) {
if (error.isConnectivityFailure()) {
isServerReachable.value = false
}
}
private fun ConnectivityManager.isCurrentlyConnected(): Boolean { private fun ConnectivityManager.isCurrentlyConnected(): Boolean {
val network = activeNetwork ?: return false val network = activeNetwork ?: return false
val caps = getNetworkCapabilities(network) ?: return false val caps = getNetworkCapabilities(network) ?: return false
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
}
private fun Throwable.isConnectivityFailure(): Boolean {
var current: Throwable? = this
while (current != null) {
when (current) {
is UnknownHostException,
is ConnectException,
is NoRouteToHostException,
is SocketTimeoutException -> return true
}
current = current.cause
}
return false
} }
} }

View File

@@ -4,6 +4,7 @@ import android.content.Context
import android.os.SystemClock import android.os.SystemClock
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import hu.bbara.purefin.core.data.JellyfinServerCandidate import hu.bbara.purefin.core.data.JellyfinServerCandidate
import hu.bbara.purefin.core.data.NetworkMonitor
import hu.bbara.purefin.core.data.PlaybackMethod import hu.bbara.purefin.core.data.PlaybackMethod
import hu.bbara.purefin.core.data.PlaybackReportContext import hu.bbara.purefin.core.data.PlaybackReportContext
import hu.bbara.purefin.core.data.QuickConnectSession import hu.bbara.purefin.core.data.QuickConnectSession
@@ -64,8 +65,9 @@ import javax.inject.Singleton
@Singleton @Singleton
class JellyfinApiClient @Inject constructor( class JellyfinApiClient @Inject constructor(
@ApplicationContext private val applicationContext: Context, @param:ApplicationContext private val applicationContext: Context,
private val userSessionRepository: UserSessionRepository, private val userSessionRepository: UserSessionRepository,
private val networkMonitor: NetworkMonitor,
) { ) {
private val jellyfin = createJellyfin { private val jellyfin = createJellyfin {
context = applicationContext context = applicationContext
@@ -583,12 +585,14 @@ class JellyfinApiClient @Inject constructor(
val startedAt = SystemClock.elapsedRealtime() val startedAt = SystemClock.elapsedRealtime()
return try { return try {
val result = block() val result = block()
networkMonitor.reportRequestSucceeded()
val elapsedMs = SystemClock.elapsedRealtime() - startedAt val elapsedMs = SystemClock.elapsedRealtime() - startedAt
Timber.tag(TAG).d("$functionName took ${elapsedMs}ms") Timber.tag(TAG).d("$functionName took ${elapsedMs}ms")
result result
} catch (error: CancellationException) { } catch (error: CancellationException) {
throw error throw error
} catch (error: Exception) { } catch (error: Exception) {
networkMonitor.reportRequestFailed(error)
val elapsedMs = SystemClock.elapsedRealtime() - startedAt val elapsedMs = SystemClock.elapsedRealtime() - startedAt
Timber.tag(TAG).e(error, "$functionName failed after ${elapsedMs}ms") Timber.tag(TAG).e(error, "$functionName failed after ${elapsedMs}ms")
throw error throw error