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

@@ -2,10 +2,10 @@ package com.m57.hermescontrol.data.remote
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import app.cash.turbine.test
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
@@ -15,13 +15,16 @@ import org.junit.Test
class NetworkMonitorTest {
@Before
fun setUp() {
val defaultNetworkField = NetworkMonitor::class.java.getDeclaredField("defaultNetwork")
defaultNetworkField.isAccessible = true
defaultNetworkField.set(NetworkMonitor, null)
val connectedField = NetworkMonitor::class.java.getDeclaredField("_isConnected")
connectedField.isAccessible = true
@Suppress("UNCHECKED_CAST")
(connectedField.get(NetworkMonitor) as MutableStateFlow<Boolean>).value = true
NetworkMonitor.resetForTest()
}
private fun createCapabilities(vararg transports: Int): NetworkCapabilities {
val capabilities = mockk<NetworkCapabilities>()
every { capabilities.hasTransport(any()) } answers {
val transport = firstArg<Int>()
transports.contains(transport)
}
return capabilities
}
@Test
@@ -94,4 +97,136 @@ class NetworkMonitorTest {
expectNoEvents()
}
}
@Test
fun `initial capabilities callback after onAvailable sets baseline and does not emit transport change`() =
runTest {
val connectivityManager = mockk<ConnectivityManager>()
val network = mockk<Network>()
val wifiCaps = createCapabilities(NetworkCapabilities.TRANSPORT_WIFI)
val callback = NetworkMonitor.createCallback(connectivityManager)
NetworkMonitor.transportChanges.test {
callback.onAvailable(network)
callback.onCapabilitiesChanged(network, wifiCaps)
expectNoEvents()
}
}
@Test
fun `capabilities change with different transports emits transport change`() =
runTest {
val connectivityManager = mockk<ConnectivityManager>()
val network = mockk<Network>()
val wifiCaps = createCapabilities(NetworkCapabilities.TRANSPORT_WIFI)
val vpnWifiCaps = createCapabilities(NetworkCapabilities.TRANSPORT_WIFI, NetworkCapabilities.TRANSPORT_VPN)
val callback = NetworkMonitor.createCallback(connectivityManager)
NetworkMonitor.transportChanges.test {
callback.onAvailable(network)
callback.onCapabilitiesChanged(network, wifiCaps)
expectNoEvents()
callback.onCapabilitiesChanged(network, vpnWifiCaps)
assertEquals(Unit, awaitItem())
expectNoEvents()
}
}
@Test
fun `capabilities change with identical transports does not emit transport change`() =
runTest {
val connectivityManager = mockk<ConnectivityManager>()
val network = mockk<Network>()
val wifiCaps1 = createCapabilities(NetworkCapabilities.TRANSPORT_WIFI)
val wifiCaps2 = createCapabilities(NetworkCapabilities.TRANSPORT_WIFI)
val callback = NetworkMonitor.createCallback(connectivityManager)
NetworkMonitor.transportChanges.test {
callback.onAvailable(network)
callback.onCapabilitiesChanged(network, wifiCaps1)
expectNoEvents()
callback.onCapabilitiesChanged(network, wifiCaps2)
expectNoEvents()
}
}
@Test
fun `capabilities change on non-default network is ignored`() =
runTest {
val connectivityManager = mockk<ConnectivityManager>()
val defaultNet = mockk<Network>()
val otherNet = mockk<Network>()
val wifiCaps = createCapabilities(NetworkCapabilities.TRANSPORT_WIFI)
val cellularCaps = createCapabilities(NetworkCapabilities.TRANSPORT_CELLULAR)
val callback = NetworkMonitor.createCallback(connectivityManager)
NetworkMonitor.transportChanges.test {
callback.onAvailable(defaultNet)
callback.onCapabilitiesChanged(defaultNet, wifiCaps)
expectNoEvents()
callback.onCapabilitiesChanged(otherNet, cellularCaps)
expectNoEvents()
}
}
@Test
fun `onLosing on default network emits transport change`() =
runTest {
val connectivityManager = mockk<ConnectivityManager>()
val defaultNet = mockk<Network>()
val callback = NetworkMonitor.createCallback(connectivityManager)
NetworkMonitor.transportChanges.test {
callback.onAvailable(defaultNet)
callback.onLosing(defaultNet, 3000)
assertEquals(Unit, awaitItem())
expectNoEvents()
}
}
@Test
fun `onLosing on non-default network is ignored`() =
runTest {
val connectivityManager = mockk<ConnectivityManager>()
val defaultNet = mockk<Network>()
val otherNet = mockk<Network>()
val callback = NetworkMonitor.createCallback(connectivityManager)
NetworkMonitor.transportChanges.test {
callback.onAvailable(defaultNet)
callback.onLosing(otherNet, 3000)
expectNoEvents()
}
}
@Test
fun `extractTransports identifies all supported transport types`() {
val caps =
createCapabilities(
NetworkCapabilities.TRANSPORT_CELLULAR,
NetworkCapabilities.TRANSPORT_WIFI,
NetworkCapabilities.TRANSPORT_ETHERNET,
NetworkCapabilities.TRANSPORT_VPN,
NetworkCapabilities.TRANSPORT_BLUETOOTH,
NetworkCapabilities.TRANSPORT_WIFI_AWARE,
)
val transports = NetworkMonitor.extractTransports(caps)
assertEquals(
setOf(
NetworkCapabilities.TRANSPORT_CELLULAR,
NetworkCapabilities.TRANSPORT_WIFI,
NetworkCapabilities.TRANSPORT_ETHERNET,
NetworkCapabilities.TRANSPORT_VPN,
NetworkCapabilities.TRANSPORT_BLUETOOTH,
NetworkCapabilities.TRANSPORT_WIFI_AWARE,
),
transports,
)
}
}
@@ -180,7 +180,9 @@ class HermesWsClientTest {
response: okhttp3.Response,
) {
webSocket.send(
"""{"jsonrpc":"2.0","id":"42","result":{"open_requests":[{"id":"srq-secret","method":"secret","params":{"session_id":"session-1","env_var":"API_KEY","prompt":"Enter API key"}}]}}""",
"""{"jsonrpc":"2.0","id":"42","result":{"open_requests":[{"id":"srq-secret",""" +
""""method":"secret","params":{"session_id":"session-1","env_var":"API_KEY",""" +
""""prompt":"Enter API key"}}]}}""",
)
}
},
@@ -2013,4 +2015,85 @@ class HermesWsClientTest {
assertEquals(latency, HermesWsClient.lastLatencyMs.value)
assertTrue(HermesWsClient.lastPongTimestamp > 0)
}
@Test
fun testProbeLivenessOnTransportChangeCancelsSocketWhenPingFails() {
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
connectedField.isAccessible = true
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
val failureLatch = CountDownLatch(1)
HermesWsClient.probeLivenessOnTransportChange(
onFailureAction = {
failureLatch.countDown()
},
)
assertTrue("Expected failure action to be invoked on failed ping", failureLatch.await(6, TimeUnit.SECONDS))
}
@Test
fun testProbeLivenessOnTransportChangeSkipsWhenDisconnected() {
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
connectedField.isAccessible = true
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
var failureInvoked = false
HermesWsClient.probeLivenessOnTransportChange(
onFailureAction = {
failureInvoked = true
},
)
Thread.sleep(100)
assertFalse(failureInvoked)
}
@Test
fun testProbeLivenessOnTransportChangeSucceedsWhenServerAnswers() {
val serverLatch = CountDownLatch(1)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: okhttp3.Response,
) {
serverLatch.countDown()
}
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
if (text.contains(""""method":"gateway.ping"""") || text.contains(""""method":"ping"""")) {
val id = Regex(""""id":"([^"]+)"""").find(text)?.groupValues?.get(1) ?: "1"
webSocket.send(
"""{"jsonrpc":"2.0","id":"$id","result":{"pong":true,"timestamp":1700000000.0}}""",
)
}
}
},
),
)
HermesWsClient.connect()
runBlocking {
withTimeout(5000) {
HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED }
}
}
assertTrue(serverLatch.await(5, TimeUnit.SECONDS))
var failureInvoked = false
HermesWsClient.probeLivenessOnTransportChange(
onFailureAction = {
failureInvoked = true
},
)
Thread.sleep(500)
assertFalse("Expected socket to remain connected when ping succeeds", failureInvoked)
}
}