diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/Amber.kt b/app/src/main/java/com/greenart7c3/nostrsigner/Amber.kt index 1838e61e..37417683 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/Amber.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/Amber.kt @@ -47,6 +47,7 @@ import com.greenart7c3.nostrsigner.okhttp.HttpClientManager import com.greenart7c3.nostrsigner.okhttp.OkHttpWebSocket import com.greenart7c3.nostrsigner.relays.AmberRelayStats import com.greenart7c3.nostrsigner.relays.NostrClientLoggerListener +import com.greenart7c3.nostrsigner.relays.RelayHealthTracker import com.greenart7c3.nostrsigner.service.ApplicationNameCache import com.greenart7c3.nostrsigner.service.BackupApplicationsWorker import com.greenart7c3.nostrsigner.service.ClearLogsWorker @@ -206,6 +207,7 @@ class Amber : val isOnMobileDataState = mutableStateOf(false) val isOnWifiDataState = mutableStateOf(false) + val isOnVpnState = mutableStateOf(false) val isOnOfflineState = mutableStateOf(false) /** npubs whose AndroidKeyStore key failed to decrypt (device KeyMint bug). */ @@ -258,7 +260,8 @@ class Amber : fun updateNetworkCapabilities(networkCapabilities: NetworkCapabilities?): Boolean { val isOnMobileData = networkCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true val isOnWifi = networkCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true - val isOffline = !isOnMobileData && !isOnWifi + val isOnVpn = networkCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true + val isOffline = networkCapabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) != true var changedNetwork = false @@ -274,6 +277,11 @@ class Amber : changedNetwork = true } + if (isOnVpnState.value != isOnVpn) { + isOnVpnState.value = isOnVpn + changedNetwork = true + } + if (isOnOfflineState.value != isOffline) { isOnOfflineState.value = isOffline changedNetwork = true @@ -571,6 +579,39 @@ class Amber : } } + /** + * Drops every relay socket and dials again from scratch. Called when the device's + * network identity changes (Wi-Fi <-> mobile, a VPN coming up or going down, the + * network regaining internet access). + * + * Sockets opened on the previous network are bound to routes that may no longer + * exist: they keep reporting "connected" and only fail once OkHttp's ping times out, + * minutes later. Relays that failed while the device was offline also carry state + * that would keep them down on the new network: Quartz's per-relay backoff (up to + * five minutes, set at once on an unresolved host) and RelayHealthTracker's dead + * list, which removes them from the subscriptions entirely. All of it is about the + * old network, so it is cleared here. + */ + suspend fun resetRelayConnections(reason: String) { + if (BuildFlavorChecker.isOfflineFlavor()) return + if (settings.killSwitch.value) return + + AmberLog.d(TAG, "Resetting relay connections: $reason") + RelayHealthTracker.reset() + + // The teardown reports onDisconnected for every relay; mark it intentional so + // NostrClientLoggerListener does not count it as failures or schedule retries. + intentionalDisconnectTime = System.currentTimeMillis() + client.disconnect() + // disconnect() also clears each relay's backoff; this covers relays without a socket. + client.resetBackoff() + client.connect() + + // Re-adds relays the dead list had dropped from the subscriptions. + checkForNewRelaysAndUpdateAllFilters() + stats.updateNotification() + } + // computeIfAbsent (not check-then-put): AppDatabase.getDatabase builds a // fresh RoomDatabase every call, so a racing second caller would build a // duplicate that loses the put() and leaks its SQLiteConnection (caught by diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/relays/NostrClientLoggerListener.kt b/app/src/main/java/com/greenart7c3/nostrsigner/relays/NostrClientLoggerListener.kt index 6e968a11..9bed8bef 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/relays/NostrClientLoggerListener.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/relays/NostrClientLoggerListener.kt @@ -60,8 +60,13 @@ class NostrClientLoggerListener( // is still worth retrying. Once a relay is dead, RelayHealthTracker also makes // NotificationSubscription.updateFilter drop it from the subscription relay // set, so Quartz stops opening sockets to it on every refresh. The streak resets on a - // successful connection (onConnected) or a network change / manual reconnect. - private fun scheduleReconnect(relay: NormalizedRelayUrl) { + // successful connection (onConnected), a network change / manual reconnect, or + // after RelayHealthTracker's cooldown. + // + // Only failed dials count (onCannotConnect). Quartz reports every failed dial as + // onCannotConnect followed by onDisconnected, so counting both halved the real + // threshold, and a plain server-side close is not a connection failure. + private fun recordFailureAndScheduleReconnect(relay: NormalizedRelayUrl) { if (!RelayHealthTracker.recordFailure(relay)) { AmberLog.d(Amber.TAG, "Relay ${relay.url} marked dead; skipping reconnect") return @@ -69,6 +74,14 @@ class NostrClientLoggerListener( reconnectWithBackoff() } + private fun scheduleReconnect(relay: NormalizedRelayUrl) { + if (RelayHealthTracker.isDead(relay)) { + AmberLog.d(Amber.TAG, "Relay ${relay.url} is dead; skipping reconnect") + return + } + reconnectWithBackoff() + } + private fun reconnectWithBackoff() { val now = System.currentTimeMillis() if (now - lastDisconnectTime > 60_000) { @@ -129,7 +142,7 @@ class NostrClientLoggerListener( return } - scheduleReconnect(relay.url) + recordFailureAndScheduleReconnect(relay.url) super.onCannotConnect(relay, errorMessage) } diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/relays/RelayHealthTracker.kt b/app/src/main/java/com/greenart7c3/nostrsigner/relays/RelayHealthTracker.kt index 4370306b..385af858 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/relays/RelayHealthTracker.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/relays/RelayHealthTracker.kt @@ -5,7 +5,7 @@ import java.util.concurrent.ConcurrentHashMap /** * Tracks consecutive connection failures per relay so the app can stop retrying - * relays that are permanently unreachable. + * relays that are unreachable. * * Quartz's relay pool is driven by the relays referenced in active subscriptions: * [NotificationSubscription.updateFilter] re-subscribes on every refresh, and any relay @@ -18,28 +18,47 @@ import java.util.concurrent.ConcurrentHashMap * them entirely. The streak is cleared when a relay connects successfully * ([recordSuccess]), and [reset] is called on every OS network change and on a * manual reconnect so previously-dead relays get a fresh chance. + * + * Being dead is not permanent: [DEAD_COOLDOWN_MS] after its last failure a relay is + * eligible again, so the periodic subscription refresh re-adds it. Without the + * cooldown a stretch of bad connectivity that never surfaced as an OS network change + * (a Wi-Fi that lost internet and got it back, a weak cell signal, a relay outage) + * marked every relay dead and the app stayed disconnected until it was force closed. */ object RelayHealthTracker { private const val MAX_RECONNECT_ATTEMPTS = 10 + const val DEAD_COOLDOWN_MS = 15 * 60 * 1000L - private val failureCounts = ConcurrentHashMap() + private class Health(val failures: Int, val lastFailureAt: Long) + + private val health = ConcurrentHashMap() + + /** Injectable for tests. */ + internal var clock: () -> Long = System::currentTimeMillis /** Records a failed connection attempt. Returns true while the relay is still worth retrying. */ fun recordFailure(relay: NormalizedRelayUrl): Boolean { - val failures = failureCounts.merge(relay, 1, Int::plus) ?: 1 - return failures <= MAX_RECONNECT_ATTEMPTS + val now = clock() + val updated = health.merge(relay, Health(1, now)) { old, _ -> Health(old.failures + 1, now) } + return updated == null || updated.failures <= MAX_RECONNECT_ATTEMPTS } /** Clears the failure streak after a successful connection. */ fun recordSuccess(relay: NormalizedRelayUrl) { - failureCounts.remove(relay) + health.remove(relay) } - /** True once a relay has failed [MAX_RECONNECT_ATTEMPTS] times in a row without recovering. */ - fun isDead(relay: NormalizedRelayUrl): Boolean = (failureCounts[relay] ?: 0) > MAX_RECONNECT_ATTEMPTS + /** + * True once a relay has failed more than [MAX_RECONNECT_ATTEMPTS] times in a row without + * recovering, until [DEAD_COOLDOWN_MS] has passed since its last failure. + */ + fun isDead(relay: NormalizedRelayUrl): Boolean { + val entry = health[relay] ?: return false + return entry.failures > MAX_RECONNECT_ATTEMPTS && clock() - entry.lastFailureAt < DEAD_COOLDOWN_MS + } /** Forgets all failure history so every relay is eligible to be retried again. */ fun reset() { - failureCounts.clear() + health.clear() } } diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/ConnectivityService.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/ConnectivityService.kt index 6d77a67d..91b0c1ef 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/ConnectivityService.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/ConnectivityService.kt @@ -16,85 +16,55 @@ import com.greenart7c3.nostrsigner.BuildFlavorChecker import com.greenart7c3.nostrsigner.LocalPreferences import com.greenart7c3.nostrsigner.models.TorMode import com.greenart7c3.nostrsigner.okhttp.HttpClientManager -import com.greenart7c3.nostrsigner.relays.RelayHealthTracker import java.util.Timer import java.util.TimerTask import kotlin.collections.set import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay import kotlinx.coroutines.launch class ConnectivityService : Service() { private val timer = Timer() private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val networkChangeDetector = NetworkChangeDetector() + private var pendingRelayReset: Job? = null + private val networkCallback = object : ConnectivityManager.NetworkCallback() { - var lastNetwork: Network? = null - override fun onAvailable(network: Network) { super.onAvailable(network) if (BuildFlavorChecker.isOfflineFlavor()) return - if (Amber.instance.settings.killSwitch.value) return - if (lastNetwork != null && lastNetwork != network) { - // New network: give previously-dead relays a fresh chance. - // RelayHealthTracker.reset() makes updateFilter re-add them - // to the subscription set (explicit refreshes plus the - // periodic safety net below). - if (Amber.instance.settings.torMode == TorMode.BUILTIN && !TorManager.isRunning.value) { - // Built-in Tor gave up earlier (bounded startup retries - // in runMigrations). The network is back, so retry now - // instead of waiting for a manual restart. - TorManager.restart(this@ConnectivityService, Amber.instance.applicationIOScope) - } - RelayHealthTracker.reset() - scope.launch(Dispatchers.IO) { - if (!Amber.instance.client.isActive()) { - Amber.instance.client.connect() - } - Amber.instance.client.reconnect(true) - } + // onCapabilitiesChanged follows right away, but read them here too in + // case it does not, so a new default network is never missed. + val connectivityManager = + (getSystemService(ConnectivityManager::class.java) as ConnectivityManager) + connectivityManager.getNetworkCapabilities(network)?.let { + onNetworkState(network, it) } - lastNetwork = network } - // Network capabilities have changed for the network override fun onCapabilitiesChanged( network: Network, networkCapabilities: NetworkCapabilities, ) { super.onCapabilitiesChanged(network, networkCapabilities) - if (BuildFlavorChecker.isOfflineFlavor()) return - if (Amber.instance.settings.killSwitch.value) return - val changed = Amber.instance.updateNetworkCapabilities(networkCapabilities) - if (changed) { - // Transport changed (e.g. wifi <-> mobile): retry dead relays. - RelayHealthTracker.reset() - } - - scope.launch(Dispatchers.IO) { - AmberLog.d( - "ServiceManager NetworkCallback", - "onCapabilitiesChanged: ${network.networkHandle} hasMobileData ${Amber.instance.isOnMobileDataState.value} hasWifi ${Amber.instance.isOnWifiDataState.value}", - ) - if (!Amber.instance.client.isActive()) { - Amber.instance.client.connect() - } - if (changed) { - Amber.instance.client.reconnect(true) - } - } + onNetworkState(network, networkCapabilities) } override fun onLost(network: Network) { super.onLost(network) if (BuildFlavorChecker.isOfflineFlavor()) return - lastNetwork = null + AmberLog.d("ServiceManager NetworkCallback", "onLost: ${network.networkHandle}") + if (!networkChangeDetector.onLost(network.networkHandle)) return + cancelPendingRelayReset() val connectivityManager = (getSystemService(ConnectivityManager::class.java) as ConnectivityManager) @@ -104,6 +74,54 @@ class ConnectivityService : Service() { } } + private fun onNetworkState(network: Network, capabilities: NetworkCapabilities) { + // Always tracked (also under the kill switch) so the detector's baseline is + // current when relays come back. + Amber.instance.updateNetworkCapabilities(capabilities) + val reason = networkChangeDetector.onNetwork(capabilities.toSnapshot(network)) + + if (Amber.instance.settings.killSwitch.value) return + + AmberLog.d( + "ServiceManager NetworkCallback", + "network ${network.networkHandle} mobile ${Amber.instance.isOnMobileDataState.value} wifi ${Amber.instance.isOnWifiDataState.value} vpn ${Amber.instance.isOnVpnState.value} change: $reason", + ) + + if (reason != null) { + if (Amber.instance.settings.torMode == TorMode.BUILTIN && !TorManager.isRunning.value) { + // Built-in Tor gave up earlier (bounded startup retries + // in runMigrations). The network is back, so retry now + // instead of waiting for a manual restart. + TorManager.restart(this, Amber.instance.applicationIOScope) + } + scheduleRelayReset(reason) + } else if (!Amber.instance.client.isActive()) { + scope.launch { + Amber.instance.client.connect() + } + } + } + + /** + * Network changes arrive as a burst of callbacks (the new network, then its + * capabilities as validation completes); wait for it to settle so the relays are + * rebuilt once, on the final network. + */ + @Synchronized + private fun scheduleRelayReset(reason: String) { + pendingRelayReset?.cancel() + pendingRelayReset = scope.launch { + delay(NETWORK_SETTLE_MS) + Amber.instance.resetRelayConnections(reason) + } + } + + @Synchronized + private fun cancelPendingRelayReset() { + pendingRelayReset?.cancel() + pendingRelayReset = null + } + override fun onBind(intent: Intent): IBinder? = null override fun onCreate() { @@ -184,6 +202,7 @@ class ConnectivityService : Service() { override fun onDestroy() { timer.cancel() + cancelPendingRelayReset() if (!BuildFlavorChecker.isOfflineFlavor()) { try { AmberLog.d(Amber.TAG, "unregisterNetworkCallback") @@ -232,5 +251,21 @@ class ConnectivityService : Service() { * nothing changed, and a 30s period prevented doze 2,880 times a day. */ const val UPDATE_FILTER_PERIOD_MS = 5 * 60 * 1000L + + private const val NETWORK_SETTLE_MS = 1_000L + + private val TRACKED_TRANSPORTS = intArrayOf( + NetworkCapabilities.TRANSPORT_CELLULAR, + NetworkCapabilities.TRANSPORT_WIFI, + NetworkCapabilities.TRANSPORT_ETHERNET, + NetworkCapabilities.TRANSPORT_BLUETOOTH, + NetworkCapabilities.TRANSPORT_VPN, + ) + + private fun NetworkCapabilities.toSnapshot(network: Network) = NetworkSnapshot( + networkId = network.networkHandle, + transports = TRACKED_TRANSPORTS.filter { hasTransport(it) }.toSet(), + validated = hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED), + ) } } diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/NetworkChangeDetector.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/NetworkChangeDetector.kt new file mode 100644 index 00000000..c243bb7a --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/NetworkChangeDetector.kt @@ -0,0 +1,79 @@ +package com.greenart7c3.nostrsigner.service + +import android.net.NetworkCapabilities.TRANSPORT_VPN + +/** + * What the relay connections care about in the device's default network. + * + * @param networkId the network's handle; a different value means a different network + * (switching Wi-Fi <-> mobile, or a VPN taking over as the default network). + * @param transports the transports the network reports (Wi-Fi, cellular, ethernet, VPN, ...). + * A VPN network also reports its underlying transport, so a VPN moving from Wi-Fi to + * mobile shows up here even though the default network itself stays the same. + * @param validated whether Android verified the network actually reaches the internet. + */ +data class NetworkSnapshot( + val networkId: Long, + val transports: Set, + val validated: Boolean, +) + +/** + * Decides when a default-network callback means the relay sockets must be rebuilt. + * + * Sockets opened on one network do not survive a move to another: the old routes are + * gone (or, with a VPN, the traffic now takes a different path) but the sockets keep + * looking connected until a ping times out. Callbacks arrive in bursts and repeat the + * same state, so this keeps the last snapshot and only reports real changes. + */ +class NetworkChangeDetector { + private var last: NetworkSnapshot? = null + private var lost = false + + /** + * Records the current default network. Returns why the relays should be reset, + * or null when nothing that matters changed. + */ + @Synchronized + fun onNetwork(current: NetworkSnapshot): String? { + val previous = last + last = current + + if (previous == null) { + // The first report after registering the callback is the network the + // relays are already connecting on; only a return after a loss is a change. + val wasLost = lost + lost = false + return if (wasLost) "network available again" else null + } + + return when { + previous.networkId != current.networkId -> describeSwitch(previous, current) + previous.transports != current.transports -> describeSwitch(previous, current) + !previous.validated && current.validated -> "network regained internet access" + else -> null + } + } + + /** + * Records that [networkId] went away. Returns true when it was the current default + * network; the loss of a network that was already replaced changes nothing. + */ + @Synchronized + fun onLost(networkId: Long): Boolean { + if (last?.networkId != networkId) return false + last = null + lost = true + return true + } + + private fun describeSwitch(previous: NetworkSnapshot, current: NetworkSnapshot): String { + val wasVpn = TRANSPORT_VPN in previous.transports + val isVpn = TRANSPORT_VPN in current.transports + return when { + !wasVpn && isVpn -> "VPN connected" + wasVpn && !isVpn -> "VPN disconnected" + else -> "network changed" + } + } +} diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/ReconnectReceiver.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/ReconnectReceiver.kt index 44801c36..5d7dbcce 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/ReconnectReceiver.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/ReconnectReceiver.kt @@ -4,16 +4,14 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import com.greenart7c3.nostrsigner.Amber -import com.greenart7c3.nostrsigner.relays.RelayHealthTracker import kotlinx.coroutines.launch class ReconnectReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { Amber.instance.applicationIOScope.launch { - // Manual reconnect: clear dead-relay state and re-run the filters so any - // relay previously dropped from the pool is re-added before reconnecting. - RelayHealthTracker.reset() - Amber.instance.checkForNewRelaysAndUpdateAllFilters(shouldReconnect = true) + // Manual reconnect: redial every relay now, ignoring dead-relay state and + // reconnect backoff, and re-add any relay previously dropped from the pool. + Amber.instance.resetRelayConnections("manual reconnect") } } } diff --git a/app/src/test/java/com/greenart7c3/nostrsigner/relays/RelayHealthTrackerTest.kt b/app/src/test/java/com/greenart7c3/nostrsigner/relays/RelayHealthTrackerTest.kt new file mode 100644 index 00000000..dbfd8906 --- /dev/null +++ b/app/src/test/java/com/greenart7c3/nostrsigner/relays/RelayHealthTrackerTest.kt @@ -0,0 +1,62 @@ +package com.greenart7c3.nostrsigner.relays + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class RelayHealthTrackerTest { + private val relay = NormalizedRelayUrl("wss://relay.example.com/") + private var now = 1_000_000L + + @Before + fun setUp() { + RelayHealthTracker.reset() + RelayHealthTracker.clock = { now } + } + + @After + fun tearDown() { + RelayHealthTracker.reset() + RelayHealthTracker.clock = System::currentTimeMillis + } + + private fun failUntilDead() { + repeat(10) { assertTrue(RelayHealthTracker.recordFailure(relay)) } + assertFalse(RelayHealthTracker.recordFailure(relay)) + } + + @Test + fun relayIsDeadAfterTooManyFailures() { + failUntilDead() + assertTrue(RelayHealthTracker.isDead(relay)) + } + + @Test + fun deadRelayGetsAnotherChanceAfterTheCooldown() { + failUntilDead() + now += RelayHealthTracker.DEAD_COOLDOWN_MS + assertFalse(RelayHealthTracker.isDead(relay)) + } + + @Test + fun failingAgainAfterTheCooldownRestartsIt() { + failUntilDead() + now += RelayHealthTracker.DEAD_COOLDOWN_MS + assertFalse(RelayHealthTracker.recordFailure(relay)) + assertTrue(RelayHealthTracker.isDead(relay)) + } + + @Test + fun successAndResetClearTheDeadState() { + failUntilDead() + RelayHealthTracker.recordSuccess(relay) + assertFalse(RelayHealthTracker.isDead(relay)) + + failUntilDead() + RelayHealthTracker.reset() + assertFalse(RelayHealthTracker.isDead(relay)) + } +} diff --git a/app/src/test/java/com/greenart7c3/nostrsigner/service/NetworkChangeDetectorTest.kt b/app/src/test/java/com/greenart7c3/nostrsigner/service/NetworkChangeDetectorTest.kt new file mode 100644 index 00000000..ef5f6c96 --- /dev/null +++ b/app/src/test/java/com/greenart7c3/nostrsigner/service/NetworkChangeDetectorTest.kt @@ -0,0 +1,80 @@ +package com.greenart7c3.nostrsigner.service + +import android.net.NetworkCapabilities.TRANSPORT_CELLULAR +import android.net.NetworkCapabilities.TRANSPORT_VPN +import android.net.NetworkCapabilities.TRANSPORT_WIFI +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class NetworkChangeDetectorTest { + private val wifi = NetworkSnapshot(1, setOf(TRANSPORT_WIFI), validated = true) + private val mobile = NetworkSnapshot(2, setOf(TRANSPORT_CELLULAR), validated = true) + private val vpnOverWifi = NetworkSnapshot(3, setOf(TRANSPORT_VPN, TRANSPORT_WIFI), validated = true) + + @Test + fun firstNetworkIsTheBaselineNotAChange() { + val detector = NetworkChangeDetector() + assertNull(detector.onNetwork(wifi)) + } + + @Test + fun repeatedCallbacksForTheSameStateAreIgnored() { + val detector = NetworkChangeDetector() + detector.onNetwork(wifi) + assertNull(detector.onNetwork(wifi)) + assertNull(detector.onNetwork(wifi.copy(validated = false))) + } + + @Test + fun switchingBetweenWifiAndMobileIsAChange() { + val detector = NetworkChangeDetector() + detector.onNetwork(wifi) + assertEquals("network changed", detector.onNetwork(mobile)) + assertEquals("network changed", detector.onNetwork(wifi)) + } + + @Test + fun vpnComingUpAndGoingDownIsAChange() { + val detector = NetworkChangeDetector() + detector.onNetwork(wifi) + assertEquals("VPN connected", detector.onNetwork(vpnOverWifi)) + assertEquals("VPN disconnected", detector.onNetwork(wifi)) + } + + @Test + fun vpnMovingToAnotherUnderlyingTransportIsAChange() { + val detector = NetworkChangeDetector() + detector.onNetwork(vpnOverWifi) + assertEquals( + "network changed", + detector.onNetwork(vpnOverWifi.copy(transports = setOf(TRANSPORT_VPN, TRANSPORT_CELLULAR))), + ) + } + + @Test + fun regainingInternetAccessIsAChange() { + val detector = NetworkChangeDetector() + detector.onNetwork(wifi.copy(validated = false)) + assertEquals("network regained internet access", detector.onNetwork(wifi)) + } + + @Test + fun networkReturningAfterALossIsAChange() { + val detector = NetworkChangeDetector() + detector.onNetwork(wifi) + assertTrue(detector.onLost(wifi.networkId)) + assertEquals("network available again", detector.onNetwork(wifi)) + } + + @Test + fun losingANetworkThatWasAlreadyReplacedIsIgnored() { + val detector = NetworkChangeDetector() + detector.onNetwork(wifi) + detector.onNetwork(mobile) + assertFalse(detector.onLost(wifi.networkId)) + assertNull(detector.onNetwork(mobile)) + } +}