diff --git a/framework/api/system-current.txt b/framework/api/system-current.txt index 764cffa92a..bdefed17b4 100644 --- a/framework/api/system-current.txt +++ b/framework/api/system-current.txt @@ -236,6 +236,7 @@ package android.net { public abstract class NetworkAgent { ctor public NetworkAgent(@NonNull android.content.Context, @NonNull android.os.Looper, @NonNull String, @NonNull android.net.NetworkCapabilities, @NonNull android.net.LinkProperties, int, @NonNull android.net.NetworkAgentConfig, @Nullable android.net.NetworkProvider); ctor public NetworkAgent(@NonNull android.content.Context, @NonNull android.os.Looper, @NonNull String, @NonNull android.net.NetworkCapabilities, @NonNull android.net.LinkProperties, @NonNull android.net.NetworkScore, @NonNull android.net.NetworkAgentConfig, @Nullable android.net.NetworkProvider); + method public void destroyAndAwaitReplacement(@IntRange(from=0, to=0x1388) int); method @Nullable public android.net.Network getNetwork(); method public void markConnected(); method public void onAddKeepalivePacketFilter(int, @NonNull android.net.KeepalivePacketData); diff --git a/framework/src/android/net/INetworkAgentRegistry.aidl b/framework/src/android/net/INetworkAgentRegistry.aidl index 08536ca0b2..2b22a5cfde 100644 --- a/framework/src/android/net/INetworkAgentRegistry.aidl +++ b/framework/src/android/net/INetworkAgentRegistry.aidl @@ -47,4 +47,5 @@ oneway interface INetworkAgentRegistry { void sendAddDscpPolicy(in DscpPolicy policy); void sendRemoveDscpPolicy(int policyId); void sendRemoveAllDscpPolicies(); + void sendDestroyAndAwaitReplacement(int timeoutMillis); } diff --git a/framework/src/android/net/NetworkAgent.java b/framework/src/android/net/NetworkAgent.java index 945e6702d0..fdc9081836 100644 --- a/framework/src/android/net/NetworkAgent.java +++ b/framework/src/android/net/NetworkAgent.java @@ -434,6 +434,14 @@ public abstract class NetworkAgent { */ public static final int CMD_DSCP_POLICY_STATUS = BASE + 28; + /** + * Sent by the NetworkAgent to ConnectivityService to notify that this network is expected to be + * replaced within the specified time by a similar network. + * arg1 = timeout in milliseconds + * @hide + */ + public static final int EVENT_DESTROY_AND_AWAIT_REPLACEMENT = BASE + 29; + private static NetworkInfo getLegacyNetworkInfo(final NetworkAgentConfig config) { final NetworkInfo ni = new NetworkInfo(config.legacyType, config.legacySubType, config.legacyTypeName, config.legacySubTypeName); @@ -942,6 +950,45 @@ public abstract class NetworkAgent { queueOrSendMessage(reg -> reg.sendTeardownDelayMs(teardownDelayMillis)); } + /** + * Indicates that this agent will likely soon be replaced by another agent for a very similar + * network (e.g., same Wi-Fi SSID). + * + * If the network is not currently satisfying any {@link NetworkRequest}s, it will be torn down. + * If it is satisfying requests, then the native network corresponding to the agent will be + * destroyed immediately, but the agent will remain registered and will continue to satisfy + * requests until {@link #unregister} is called, the network is replaced by an equivalent or + * better network, or the specified timeout expires. During this time: + * + * + * + * Once this method is called, it is not possible to restore the agent to a functioning state. + * If a replacement network becomes available, then a new agent must be registered. When that + * replacement network is fully capable of replacing this network (including, possibly, being + * validated), this agent will no longer be needed and will be torn down. Otherwise, this agent + * can be disconnected by calling {@link #unregister}. If {@link #unregister} is not called, + * this agent will automatically be unregistered when the specified timeout expires. Any + * teardown delay previously set using{@link #setTeardownDelayMillis} is ignored. + * + *

This method has no effect if {@link #markConnected} has not yet been called. + *

This method may only be called once. + * + * @param timeoutMillis the timeout after which this network will be unregistered even if + * {@link #unregister} was not called. + */ + public void destroyAndAwaitReplacement( + @IntRange(from = 0, to = MAX_TEARDOWN_DELAY_MS) int timeoutMillis) { + queueOrSendMessage(reg -> reg.sendDestroyAndAwaitReplacement(timeoutMillis)); + } + /** * Change the legacy subtype of this network agent. * diff --git a/service/src/com/android/server/ConnectivityService.java b/service/src/com/android/server/ConnectivityService.java index dd92a18768..d4833412dd 100644 --- a/service/src/com/android/server/ConnectivityService.java +++ b/service/src/com/android/server/ConnectivityService.java @@ -3502,6 +3502,12 @@ public class ConnectivityService extends IConnectivityManager.Stub return false; } + private boolean isDisconnectRequest(Message msg) { + if (msg.what != NetworkAgent.EVENT_NETWORK_INFO_CHANGED) return false; + final NetworkInfo info = (NetworkInfo) ((Pair) msg.obj).second; + return info.getState() == NetworkInfo.State.DISCONNECTED; + } + // must be stateless - things change under us. private class NetworkStateTrackerHandler extends Handler { public NetworkStateTrackerHandler(Looper looper) { @@ -3518,6 +3524,11 @@ public class ConnectivityService extends IConnectivityManager.Stub return; } + // If the network has been destroyed, the only thing that it can do is disconnect. + if (nai.destroyed && !isDisconnectRequest(msg)) { + return; + } + switch (msg.what) { case NetworkAgent.EVENT_NETWORK_CAPABILITIES_CHANGED: { final NetworkCapabilities networkCapabilities = new NetworkCapabilities( @@ -3619,12 +3630,60 @@ public class ConnectivityService extends IConnectivityManager.Stub } break; } + case NetworkAgent.EVENT_DESTROY_AND_AWAIT_REPLACEMENT: { + // If nai is not yet created, or is already destroyed, ignore. + if (!shouldDestroyNativeNetwork(nai)) break; + + final int timeoutMs = (int) arg.second; + if (timeoutMs < 0 || timeoutMs > NetworkAgent.MAX_TEARDOWN_DELAY_MS) { + Log.e(TAG, "Invalid network replacement timer " + timeoutMs + + ", must be between 0 and " + NetworkAgent.MAX_TEARDOWN_DELAY_MS); + } + + // Marking a network awaiting replacement is used to ensure that any requests + // satisfied by the network do not switch to another network until a + // replacement is available or the wait for a replacement times out. + // If the network is inactive (i.e., nascent or lingering), then there are no + // such requests, and there is no point keeping it. Just tear it down. + // Note that setLingerDuration(0) cannot be used to do this because the network + // could be nascent. + nai.clearInactivityState(); + if (unneeded(nai, UnneededFor.TEARDOWN)) { + Log.d(TAG, nai.toShortString() + + " marked awaiting replacement is unneeded, tearing down instead"); + teardownUnneededNetwork(nai); + break; + } + + Log.d(TAG, "Marking " + nai.toShortString() + + " destroyed, awaiting replacement within " + timeoutMs + "ms"); + destroyNativeNetwork(nai); + + // TODO: deduplicate this call with the one in disconnectAndDestroyNetwork. + // This is not trivial because KeepaliveTracker#handleStartKeepalive does not + // consider the fact that the network could already have disconnected or been + // destroyed. Fix the code to send ERROR_INVALID_NETWORK when this happens + // (taking care to ensure no dup'd FD leaks), then remove the code duplication + // and move this code to a sensible location (destroyNativeNetwork perhaps?). + mKeepaliveTracker.handleStopAllKeepalives(nai, + SocketKeepalive.ERROR_INVALID_NETWORK); + + nai.updateScoreForNetworkAgentUpdate(); + // This rematch is almost certainly not going to result in any changes, because + // the destroyed flag is only just above the "current satisfier wins" + // tie-breaker. But technically anything that affects scoring should rematch. + rematchAllNetworksAndRequests(); + mHandler.postDelayed(() -> nai.disconnect(), timeoutMs); + break; + } } } private boolean maybeHandleNetworkMonitorMessage(Message msg) { final int netId = msg.arg2; final NetworkAgentInfo nai = getNetworkAgentInfoForNetId(netId); + // If a network has already been destroyed, all NetworkMonitor updates are ignored. + if (nai != null && nai.destroyed) return true; switch (msg.what) { default: return false; @@ -4124,6 +4183,10 @@ public class ConnectivityService extends IConnectivityManager.Stub } } + private static boolean shouldDestroyNativeNetwork(@NonNull NetworkAgentInfo nai) { + return nai.created && !nai.destroyed; + } + private void handleNetworkAgentDisconnected(Message msg) { NetworkAgentInfo nai = (NetworkAgentInfo) msg.obj; disconnectAndDestroyNetwork(nai); @@ -4230,7 +4293,7 @@ public class ConnectivityService extends IConnectivityManager.Stub } private void destroyNetwork(NetworkAgentInfo nai) { - if (nai.created) { + if (shouldDestroyNativeNetwork(nai)) { // Tell netd to clean up the configuration for this network // (routing rules, DNS, etc). // This may be slow as it requires a lot of netd shelling out to ip and @@ -4239,15 +4302,15 @@ public class ConnectivityService extends IConnectivityManager.Stub // network or service a new request from an app), so network traffic isn't interrupted // for an unnecessarily long time. destroyNativeNetwork(nai); - mDnsManager.removeNetwork(nai.network); - - // clean up tc police filters on interface. - if (nai.everConnected && canNetworkBeRateLimited(nai) && mIngressRateLimit >= 0) { - mDeps.disableIngressRateLimit(nai.linkProperties.getInterfaceName()); - } + } + if (!nai.created && !SdkLevel.isAtLeastT()) { + // Backwards compatibility: send onNetworkDestroyed even if network was never created. + // This can never run if the code above runs because shouldDestroyNativeNetwork is + // false if the network was never created. + // TODO: delete when S is no longer supported. + nai.onNetworkDestroyed(); } mNetIdManager.releaseNetId(nai.network.getNetId()); - nai.onNetworkDestroyed(); } private boolean createNativeNetwork(@NonNull NetworkAgentInfo nai) { @@ -4290,6 +4353,18 @@ public class ConnectivityService extends IConnectivityManager.Stub } catch (RemoteException | ServiceSpecificException e) { loge("Exception destroying network: " + e); } + // TODO: defer calling this until the network is removed from mNetworkAgentInfos. + // Otherwise, a private DNS configuration update for a destroyed network, or one that never + // gets created, could add data to DnsManager data structures that will never get deleted. + mDnsManager.removeNetwork(nai.network); + + // clean up tc police filters on interface. + if (nai.everConnected && canNetworkBeRateLimited(nai) && mIngressRateLimit >= 0) { + mDeps.disableIngressRateLimit(nai.linkProperties.getInterfaceName()); + } + + nai.destroyed = true; + nai.onNetworkDestroyed(); } // If this method proves to be too slow then we can maintain a separate @@ -8542,11 +8617,19 @@ public class ConnectivityService extends IConnectivityManager.Stub log(" accepting network in place of " + previousSatisfier.toShortString()); } previousSatisfier.removeRequest(previousRequest.requestId); - if (canSupportGracefulNetworkSwitch(previousSatisfier, newSatisfier)) { + if (canSupportGracefulNetworkSwitch(previousSatisfier, newSatisfier) + && !previousSatisfier.destroyed) { // If this network switch can't be supported gracefully, the request is not // lingered. This allows letting go of the network sooner to reclaim some // performance on the new network, since the radio can't do both at the same // time while preserving good performance. + // + // Also don't linger the request if the old network has been destroyed. + // A destroyed network does not provide actual network connectivity, so + // lingering it is not useful. In particular this ensures that a destroyed + // network is outscored by its replacement, + // then it is torn down immediately instead of being lingered, and any apps that + // were using it immediately get onLost and can connect using the new network. previousSatisfier.lingerRequest(previousRequest.requestId, now); } } else { diff --git a/service/src/com/android/server/connectivity/Nat464Xlat.java b/service/src/com/android/server/connectivity/Nat464Xlat.java index c66a280f2b..7b06682d11 100644 --- a/service/src/com/android/server/connectivity/Nat464Xlat.java +++ b/service/src/com/android/server/connectivity/Nat464Xlat.java @@ -132,8 +132,8 @@ public class Nat464Xlat { final boolean skip464xlat = (nai.netAgentConfig() != null) && nai.netAgentConfig().skip464xlat; - return supported && connected && isIpv6OnlyNetwork && !skip464xlat - && (nai.networkCapabilities.hasTransport(TRANSPORT_CELLULAR) + return supported && connected && isIpv6OnlyNetwork && !skip464xlat && !nai.destroyed + && (nai.networkCapabilities.hasTransport(TRANSPORT_CELLULAR) ? isCellular464XlatEnabled() : true); } diff --git a/service/src/com/android/server/connectivity/NetworkAgentInfo.java b/service/src/com/android/server/connectivity/NetworkAgentInfo.java index e29d616c1f..ee45e5cf76 100644 --- a/service/src/com/android/server/connectivity/NetworkAgentInfo.java +++ b/service/src/com/android/server/connectivity/NetworkAgentInfo.java @@ -732,6 +732,12 @@ public class NetworkAgentInfo implements Comparable, NetworkRa mHandler.obtainMessage(NetworkAgent.EVENT_REMOVE_ALL_DSCP_POLICIES, new Pair<>(NetworkAgentInfo.this, null)).sendToTarget(); } + + @Override + public void sendDestroyAndAwaitReplacement(final int timeoutMillis) { + mHandler.obtainMessage(NetworkAgent.EVENT_DESTROY_AND_AWAIT_REPLACEMENT, + new Pair<>(NetworkAgentInfo.this, timeoutMillis)).sendToTarget(); + } } /** @@ -976,7 +982,7 @@ public class NetworkAgentInfo implements Comparable, NetworkRa /** * Update the ConnectivityService-managed bits in the score. * - * Call this after updating the network agent config. + * Call this after changing any data that might affect the score (e.g., agent config). */ public void updateScoreForNetworkAgentUpdate() { mScore = mScore.mixInScore(networkCapabilities, networkAgentConfig, @@ -1256,6 +1262,8 @@ public class NetworkAgentInfo implements Comparable, NetworkRa + "network{" + network + "} handle{" + network.getNetworkHandle() + "} ni{" + networkInfo.toShortString() + "} " + mScore + " " + + (created ? " created" : "") + + (destroyed ? " destroyed" : "") + (isNascent() ? " nascent" : (isLingering() ? " lingering" : "")) + (everValidated ? " everValidated" : "") + (lastValidated ? " lastValidated" : "") diff --git a/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt b/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt index 225602fe55..af567ff773 100644 --- a/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt +++ b/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt @@ -19,6 +19,7 @@ import android.Manifest.permission.NETWORK_SETTINGS import android.app.Instrumentation import android.content.Context import android.net.ConnectivityManager +import android.net.EthernetNetworkSpecifier import android.net.INetworkAgent import android.net.INetworkAgentRegistry import android.net.InetAddresses @@ -35,6 +36,7 @@ import android.net.NetworkCapabilities import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_METERED +import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VCN_MANAGED @@ -42,7 +44,9 @@ import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VPN import android.net.NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED import android.net.NetworkCapabilities.NET_CAPABILITY_TRUSTED import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED +import android.net.NetworkCapabilities.TRANSPORT_CELLULAR import android.net.NetworkCapabilities.TRANSPORT_TEST +import android.net.NetworkCapabilities.TRANSPORT_WIFI import android.net.NetworkCapabilities.TRANSPORT_VPN import android.net.NetworkInfo import android.net.NetworkProvider @@ -100,6 +104,7 @@ import com.android.testutils.TestableNetworkAgent.CallbackEntry.OnStopSocketKeep import com.android.testutils.TestableNetworkAgent.CallbackEntry.OnUnregisterQosCallback import com.android.testutils.TestableNetworkAgent.CallbackEntry.OnValidationStatus import com.android.testutils.TestableNetworkCallback +import com.android.testutils.assertThrows import org.junit.After import org.junit.Assume.assumeFalse import org.junit.Before @@ -112,6 +117,8 @@ import org.mockito.Mockito.doReturn import org.mockito.Mockito.mock import org.mockito.Mockito.timeout import org.mockito.Mockito.verify +import java.io.IOException +import java.net.DatagramSocket import java.net.InetAddress import java.net.InetSocketAddress import java.net.Socket @@ -249,6 +256,28 @@ class NetworkAgentTest { .build() } + private fun makeTestNetworkCapabilities( + specifier: String? = null, + transports: IntArray = intArrayOf() + ) = NetworkCapabilities().apply { + addTransportType(TRANSPORT_TEST) + removeCapability(NET_CAPABILITY_TRUSTED) + removeCapability(NET_CAPABILITY_INTERNET) + addCapability(NET_CAPABILITY_NOT_SUSPENDED) + addCapability(NET_CAPABILITY_NOT_ROAMING) + addCapability(NET_CAPABILITY_NOT_VPN) + if (SdkLevel.isAtLeastS()) { + addCapability(NET_CAPABILITY_NOT_VCN_MANAGED) + } + if (null != specifier) { + setNetworkSpecifier(CompatUtil.makeEthernetNetworkSpecifier(specifier)) + } + for (t in transports) { addTransportType(t) } + // Most transports are not allowed on test networks unless the network is marked restricted. + // This test does not need + if (transports.size > 0) removeCapability(NET_CAPABILITY_NOT_RESTRICTED) + } + private fun createNetworkAgent( context: Context = realContext, specifier: String? = null, @@ -256,20 +285,7 @@ class NetworkAgentTest { initialLp: LinkProperties? = null, initialConfig: NetworkAgentConfig? = null ): TestableNetworkAgent { - val nc = initialNc ?: NetworkCapabilities().apply { - addTransportType(TRANSPORT_TEST) - removeCapability(NET_CAPABILITY_TRUSTED) - removeCapability(NET_CAPABILITY_INTERNET) - addCapability(NET_CAPABILITY_NOT_SUSPENDED) - addCapability(NET_CAPABILITY_NOT_ROAMING) - addCapability(NET_CAPABILITY_NOT_VPN) - if (SdkLevel.isAtLeastS()) { - addCapability(NET_CAPABILITY_NOT_VCN_MANAGED) - } - if (null != specifier) { - setNetworkSpecifier(CompatUtil.makeEthernetNetworkSpecifier(specifier)) - } - } + val nc = initialNc ?: makeTestNetworkCapabilities(specifier) val lp = initialLp ?: LinkProperties().apply { addLinkAddress(LinkAddress(LOCAL_IPV4_ADDRESS, 32)) addRoute(RouteInfo(IpPrefix("0.0.0.0/0"), null, null)) @@ -284,12 +300,14 @@ class NetworkAgentTest { context: Context = realContext, specifier: String? = UUID.randomUUID().toString(), initialConfig: NetworkAgentConfig? = null, - expectedInitSignalStrengthThresholds: IntArray? = intArrayOf() + expectedInitSignalStrengthThresholds: IntArray? = intArrayOf(), + transports: IntArray = intArrayOf() ): Pair { val callback = TestableNetworkCallback() // Ensure this NetworkAgent is never unneeded by filing a request with its specifier. requestNetwork(makeTestNetworkRequest(specifier = specifier), callback) - val agent = createNetworkAgent(context, specifier, initialConfig = initialConfig) + val nc = makeTestNetworkCapabilities(specifier, transports) + val agent = createNetworkAgent(context, initialConfig = initialConfig, initialNc = nc) agent.setTeardownDelayMillis(0) // Connect the agent and verify initial status callbacks. agent.register() @@ -301,6 +319,15 @@ class NetworkAgentTest { return agent to callback } + private fun connectNetwork(vararg transports: Int): Pair { + val (agent, callback) = createConnectedNetworkAgent(transports = transports) + val network = agent.network!! + // createConnectedNetworkAgent internally files a request; release it so that the network + // will be torn down if unneeded. + mCM.unregisterNetworkCallback(callback) + return agent to network + } + private fun createNetworkAgentWithFakeCS() = createNetworkAgent().also { mFakeConnectivityService.connect(it.registerForTest(Network(FAKE_NET_ID))) } @@ -1123,4 +1150,138 @@ class NetworkAgentTest { remoteAddresses ) } + + @Test + fun testDestroyAndAwaitReplacement() { + // Keeps an eye on all test networks. + val matchAllCallback = TestableNetworkCallback(timeoutMs = DEFAULT_TIMEOUT_MS) + registerNetworkCallback(makeTestNetworkRequest(), matchAllCallback) + + // File a request that matches and keeps up the best-scoring test network. + val testCallback = TestableNetworkCallback(timeoutMs = DEFAULT_TIMEOUT_MS) + requestNetwork(makeTestNetworkRequest(), testCallback) + + // Connect the first network. This should satisfy the request. + val (agent1, network1) = connectNetwork() + matchAllCallback.expectAvailableThenValidatedCallbacks(network1) + testCallback.expectAvailableThenValidatedCallbacks(network1) + // Check that network1 exists by binding a socket to it and getting no exceptions. + network1.bindSocket(DatagramSocket()) + + // Connect a second agent. network1 is preferred because it was already registered, so + // testCallback will not see any events. agent2 is be torn down because it has no requests. + val (agent2, network2) = connectNetwork() + matchAllCallback.expectAvailableThenValidatedCallbacks(network2) + matchAllCallback.expectCallback(network2) + agent2.expectCallback() + agent2.expectCallback() + assertNull(mCM.getLinkProperties(network2)) + + // Mark the first network as awaiting replacement. This should destroy the underlying + // native network and send onNetworkDestroyed, but will not send any NetworkCallbacks, + // because for callback and scoring purposes network1 is still connected. + agent1.destroyAndAwaitReplacement(5_000 /* timeoutMillis */) + agent1.expectCallback() + assertThrows(IOException::class.java) { network1.bindSocket(DatagramSocket()) } + assertNotNull(mCM.getLinkProperties(network1)) + + // Calling destroyAndAwaitReplacement more than once has no effect. + // If it did, this test would fail because the 1ms timeout means that the network would be + // torn down before the replacement arrives. + agent1.destroyAndAwaitReplacement(1 /* timeoutMillis */) + + // Connect a third network. Because network1 is awaiting replacement, network3 is preferred + // as soon as it validates (until then, it is outscored by network1). + // The fact that the first events seen by matchAllCallback is the connection of network3 + // implicitly ensures that no callbacks are sent since network1 was lost. + val (agent3, network3) = connectNetwork() + matchAllCallback.expectAvailableThenValidatedCallbacks(network3) + testCallback.expectAvailableDoubleValidatedCallbacks(network3) + + // As soon as the replacement arrives, network1 is disconnected. + // Check that this happens before the replacement timeout (5 seconds) fires. + matchAllCallback.expectCallback(network1, 2_000 /* timeoutMs */) + agent1.expectCallback() + + // Test lingering: + // - Connect a higher-scoring network and check that network3 starts lingering. + // - Mark network3 awaiting replacement. + // - Check that network3 is torn down immediately without waiting for the linger timer or + // the replacement timer to fire. This is a regular teardown, so it results in + // onNetworkUnwanted before onNetworkDestroyed. + val (agent4, agent4callback) = createConnectedNetworkAgent() + val network4 = agent4.network!! + matchAllCallback.expectAvailableThenValidatedCallbacks(network4) + agent4.sendNetworkScore(NetworkScore.Builder().setTransportPrimary(true).build()) + matchAllCallback.expectCallback(network3) + testCallback.expectAvailableCallbacks(network4, validated = true) + mCM.unregisterNetworkCallback(agent4callback) + agent3.destroyAndAwaitReplacement(5_000) + agent3.expectCallback() + matchAllCallback.expectCallback(network3, 1000L) + agent3.expectCallback() + + // Now mark network4 awaiting replacement with a low timeout, and check that if no + // replacement arrives, it is torn down. + agent4.destroyAndAwaitReplacement(100 /* timeoutMillis */) + matchAllCallback.expectCallback(network4, 1000L /* timeoutMs */) + testCallback.expectCallback(network4, 1000L /* timeoutMs */) + agent4.expectCallback() + agent4.expectCallback() + + // If a network that is awaiting replacement is unregistered, it disconnects immediately, + // before the replacement timeout fires. + val (agent5, network5) = connectNetwork() + matchAllCallback.expectAvailableThenValidatedCallbacks(network5) + testCallback.expectAvailableThenValidatedCallbacks(network5) + agent5.destroyAndAwaitReplacement(5_000 /* timeoutMillis */) + agent5.unregister() + matchAllCallback.expectCallback(network5, 1000L /* timeoutMs */) + testCallback.expectCallback(network5, 1000L /* timeoutMs */) + agent5.expectCallback() + agent5.expectCallback() + + // If wifi is replaced within the timeout, the device does not switch to cellular. + val (cellAgent, cellNetwork) = connectNetwork(TRANSPORT_CELLULAR) + testCallback.expectAvailableThenValidatedCallbacks(cellNetwork) + matchAllCallback.expectAvailableThenValidatedCallbacks(cellNetwork) + + val (wifiAgent, wifiNetwork) = connectNetwork(TRANSPORT_WIFI) + testCallback.expectAvailableCallbacks(wifiNetwork, validated = true) + testCallback.expectCapabilitiesThat(wifiNetwork) { + it.hasCapability(NET_CAPABILITY_VALIDATED) + } + matchAllCallback.expectAvailableCallbacks(wifiNetwork, validated = false) + matchAllCallback.expectCallback(cellNetwork) + matchAllCallback.expectCapabilitiesThat(wifiNetwork) { + it.hasCapability(NET_CAPABILITY_VALIDATED) + } + + wifiAgent.destroyAndAwaitReplacement(5_000 /* timeoutMillis */) + wifiAgent.expectCallback() + + // Once the network is awaiting replacement, changing LinkProperties, NetworkCapabilities or + // score, or calling reportNetworkConnectivity, have no effect. + val wifiSpecifier = mCM.getNetworkCapabilities(wifiNetwork)!!.networkSpecifier + assertNotNull(wifiSpecifier) + assertTrue(wifiSpecifier is EthernetNetworkSpecifier) + + val wifiNc = makeTestNetworkCapabilities(wifiSpecifier.interfaceName, + intArrayOf(TRANSPORT_WIFI)) + wifiAgent.sendNetworkCapabilities(wifiNc) + val wifiLp = mCM.getLinkProperties(wifiNetwork)!! + val newRoute = RouteInfo(IpPrefix("192.0.2.42/24")) + assertFalse(wifiLp.getRoutes().contains(newRoute)) + wifiLp.addRoute(newRoute) + wifiAgent.sendLinkProperties(wifiLp) + mCM.reportNetworkConnectivity(wifiNetwork, false) + // The test implicitly checks that no callbacks are sent here, because the next events seen + // by the callbacks are for the new network connecting. + + val (newWifiAgent, newWifiNetwork) = connectNetwork(TRANSPORT_WIFI) + testCallback.expectAvailableCallbacks(newWifiNetwork, validated = true) + matchAllCallback.expectAvailableThenValidatedCallbacks(newWifiNetwork) + matchAllCallback.expectCallback(wifiNetwork) + wifiAgent.expectCallback() + } }