perf(#1165): add onCapabilitiesChanged to NetworkMonitor for faster socket recovery (#1179)

This commit is contained in:
M57 authored and GitHub committed 2026-09-17 19:40:02 +03:00
1 parent 8c5a3a061f
commit 880adbef4f
4 files changed
+349 -11

No files matched your search

@@ -3,6 +3,9 @@ package com.m57.hermescontrol.data.remote
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.os.Build
import androidx.annotation.VisibleForTesting
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
@@ -19,6 +22,10 @@ object NetworkMonitor {
private val networkChangeEvents = MutableSharedFlow<Boolean>(extraBufferCapacity = Int.MAX_VALUE)
internal val networkChanges: SharedFlow<Boolean> = networkChangeEvents.asSharedFlow()
// Emitted when default network transports change without onLost/onAvailable (issue #1165).
private val transportChangeEvents = MutableSharedFlow<Unit>(extraBufferCapacity = Int.MAX_VALUE)
internal val transportChanges: SharedFlow<Unit> = transportChangeEvents.asSharedFlow()
private val callbackLock = Any()
private val callbackGeneration = AtomicInteger(0)
private var callbackRegistration: Pair<ConnectivityManager, ConnectivityManager.NetworkCallback>? = null
@@ -26,6 +33,9 @@ object NetworkMonitor {
@Volatile
private var defaultNetwork: Network? = null
@Volatile
private var currentTransports: Set<Int>? = null
fun init(context: Context) {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val callback = createCallback(cm)
@@ -38,6 +48,10 @@ object NetworkMonitor {
defaultNetwork = activeNetwork
networkAvailable = activeNetwork != null
_isConnected.value = networkAvailable
currentTransports =
runCatching {
activeNetwork?.let { cm.getNetworkCapabilities(it) }?.let { extractTransports(it) }
}.getOrNull()
callbackRegistration.also { callbackRegistration = cm to callback }
}
if (networkChanged) networkChangeEvents.tryEmit(networkAvailable)
@@ -56,6 +70,7 @@ object NetworkMonitor {
if (callbackGeneration.get() != generation) return
(defaultNetwork != network).also {
defaultNetwork = network
currentTransports = null
_isConnected.value = true
}
}
@@ -71,11 +86,86 @@ object NetworkMonitor {
(defaultNetwork != activeNetwork).also {
defaultNetwork = activeNetwork
networkAvailable = activeNetwork != null
currentTransports = null
_isConnected.value = networkAvailable
}
}
if (changed) networkChangeEvents.tryEmit(networkAvailable)
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities,
) {
val transportChanged: Boolean
synchronized(callbackLock) {
if (callbackGeneration.get() != generation || defaultNetwork != network) return
val newTransports = extractTransports(networkCapabilities)
val oldTransports = currentTransports
currentTransports = newTransports
transportChanged = oldTransports != null && oldTransports != newTransports
}
if (transportChanged) {
transportChangeEvents.tryEmit(Unit)
}
}
override fun onLosing(
network: Network,
maxMsToLive: Int,
) {
val isDefault: Boolean
synchronized(callbackLock) {
if (callbackGeneration.get() != generation || defaultNetwork != network) return
isDefault = true
}
if (isDefault) {
transportChangeEvents.tryEmit(Unit)
}
}
}
}
internal fun extractTransports(capabilities: NetworkCapabilities?): Set<Int> {
if (capabilities == null) return emptySet()
val transports = mutableSetOf<Int>()
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
transports.add(NetworkCapabilities.TRANSPORT_CELLULAR)
}
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
transports.add(NetworkCapabilities.TRANSPORT_WIFI)
}
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH)) {
transports.add(NetworkCapabilities.TRANSPORT_BLUETOOTH)
}
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) {
transports.add(NetworkCapabilities.TRANSPORT_ETHERNET)
}
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
transports.add(NetworkCapabilities.TRANSPORT_VPN)
}
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI_AWARE)) {
transports.add(NetworkCapabilities.TRANSPORT_WIFI_AWARE)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 &&
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_LOWPAN)
) {
transports.add(NetworkCapabilities.TRANSPORT_LOWPAN)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_USB)
) {
transports.add(NetworkCapabilities.TRANSPORT_USB)
}
return transports
}
@VisibleForTesting
internal fun resetForTest() {
synchronized(callbackLock) {
defaultNetwork = null
currentTransports = null
_isConnected.value = true
}
}
}
@@ -106,10 +106,11 @@ object HermesWsClient {
/**
* Liveness thresholds aligned with reference clients (desktop/web):
* Ping every 15s using gateway.ping; 45s inbound silence deadline.
* Ping every 15s using gateway.ping; 30s inbound silence deadline (issue #1165)
* as mobile secondary safety net for faster half-open socket recovery.
*/
private const val HEARTBEAT_INTERVAL_MS = 15_000L
private const val STALE_THRESHOLD_MS = 45_000L
private const val STALE_THRESHOLD_MS = 30_000L
private const val LIVENESS_PROBE_TIMEOUT_MS = 5_000L
// ── Internal state (all access through synchronized / atomic) ────────
@@ -369,6 +370,7 @@ object HermesWsClient {
val isConnected: Boolean get() = connected.get()
private var foregroundProbeJob: Job? = null
private var transportProbeJob: Job? = null
fun setAppForeground(foreground: Boolean) {
appInForeground.set(foreground)
@@ -398,6 +400,29 @@ object HermesWsClient {
}
}
@VisibleForTesting
internal fun probeLivenessOnTransportChange(
onFailureAction: () -> Unit = {
synchronized(outboundLock) {
if (connected.get()) webSocket?.cancel()
}
},
) {
if (!connected.get()) return
transportProbeJob?.cancel()
transportProbeJob =
wsScope.launch {
Log.d(TAG, "Network transport changed — probing WebSocket liveness")
val alive = runCatching { ping(LIVENESS_PROBE_TIMEOUT_MS) }.isSuccess
if (alive) {
Log.d(TAG, "WebSocket liveness probe succeeded on new transport")
return@launch
}
Log.w(TAG, "Transport change liveness probe failed — cancelling socket")
onFailureAction()
}
}
fun acquireExternalActivityConnectionLease() {
externalActivityConnectionLease.set(true)
}
@@ -428,6 +453,11 @@ object HermesWsClient {
reconnectForNetworkChange(networkAvailable)
}
}
wsScope.launch {
NetworkMonitor.transportChanges.collect {
probeLivenessOnTransportChange()
}
}
}
var socketToCancel: WebSocket? = null
val shouldOpen =