Merge history of CTS
BUG: 167962976
Test: TH
Merged-In: I30d52c4df571c894b7797300e8f56ddd4b2cc2dd
Change-Id: Id343f5a31604abfe70140343bffab20503cd2705
diff --git a/tests/cts/net/util/Android.bp b/tests/cts/net/util/Android.bp
new file mode 100644
index 0000000..c36d976
--- /dev/null
+++ b/tests/cts/net/util/Android.bp
@@ -0,0 +1,26 @@
+//
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+// Common utilities for cts net tests.
+java_library {
+ name: "cts-net-utils",
+ srcs: ["java/**/*.java", "java/**/*.kt"],
+ static_libs: [
+ "compatibility-device-util-axt",
+ "junit",
+ "net-tests-utils",
+ ],
+}
\ No newline at end of file
diff --git a/tests/cts/net/util/java/android/net/cts/util/CtsNetUtils.java b/tests/cts/net/util/java/android/net/cts/util/CtsNetUtils.java
new file mode 100644
index 0000000..be0daae
--- /dev/null
+++ b/tests/cts/net/util/java/android/net/cts/util/CtsNetUtils.java
@@ -0,0 +1,693 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.cts.util;
+
+import static android.Manifest.permission.NETWORK_SETTINGS;
+import static android.net.ConnectivityManager.PRIVATE_DNS_MODE_OPPORTUNISTIC;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET;
+import static android.net.NetworkCapabilities.TRANSPORT_CELLULAR;
+import static android.net.NetworkCapabilities.TRANSPORT_TEST;
+import static android.net.wifi.WifiManager.SCAN_RESULTS_AVAILABLE_ACTION;
+
+import static com.android.testutils.TestPermissionUtil.runAsShell;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import android.annotation.NonNull;
+import android.app.AppOpsManager;
+import android.content.BroadcastReceiver;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.PackageManager;
+import android.net.ConnectivityManager;
+import android.net.ConnectivityManager.NetworkCallback;
+import android.net.LinkProperties;
+import android.net.Network;
+import android.net.NetworkCapabilities;
+import android.net.NetworkInfo;
+import android.net.NetworkInfo.State;
+import android.net.NetworkRequest;
+import android.net.TestNetworkManager;
+import android.net.wifi.ScanResult;
+import android.net.wifi.WifiConfiguration;
+import android.net.wifi.WifiInfo;
+import android.net.wifi.WifiManager;
+import android.os.Binder;
+import android.os.Build;
+import android.os.IBinder;
+import android.os.SystemProperties;
+import android.provider.Settings;
+import android.system.Os;
+import android.system.OsConstants;
+import android.util.Log;
+
+import com.android.compatibility.common.util.SystemUtil;
+
+import junit.framework.AssertionFailedError;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+public final class CtsNetUtils {
+ private static final String TAG = CtsNetUtils.class.getSimpleName();
+ private static final int DURATION = 10000;
+ private static final int SOCKET_TIMEOUT_MS = 2000;
+ private static final int PRIVATE_DNS_PROBE_MS = 1_000;
+
+ private static final int PRIVATE_DNS_SETTING_TIMEOUT_MS = 6_000;
+ private static final int CONNECTIVITY_CHANGE_TIMEOUT_SECS = 30;
+ public static final int HTTP_PORT = 80;
+ public static final String TEST_HOST = "connectivitycheck.gstatic.com";
+ public static final String HTTP_REQUEST =
+ "GET /generate_204 HTTP/1.0\r\n" +
+ "Host: " + TEST_HOST + "\r\n" +
+ "Connection: keep-alive\r\n\r\n";
+ // Action sent to ConnectivityActionReceiver when a network callback is sent via PendingIntent.
+ public static final String NETWORK_CALLBACK_ACTION =
+ "ConnectivityManagerTest.NetworkCallbackAction";
+
+ private final IBinder mBinder = new Binder();
+ private final Context mContext;
+ private final ConnectivityManager mCm;
+ private final ContentResolver mCR;
+ private final WifiManager mWifiManager;
+ private TestNetworkCallback mCellNetworkCallback;
+ private String mOldPrivateDnsMode;
+ private String mOldPrivateDnsSpecifier;
+
+ public CtsNetUtils(Context context) {
+ mContext = context;
+ mCm = (ConnectivityManager) mContext.getSystemService(Context.CONNECTIVITY_SERVICE);
+ mWifiManager = (WifiManager) mContext.getSystemService(Context.WIFI_SERVICE);
+ mCR = context.getContentResolver();
+ }
+
+ /** Checks if FEATURE_IPSEC_TUNNELS is enabled on the device */
+ public boolean hasIpsecTunnelsFeature() {
+ return mContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_IPSEC_TUNNELS)
+ || SystemProperties.getInt("ro.product.first_api_level", 0)
+ >= Build.VERSION_CODES.Q;
+ }
+
+ /**
+ * Sets the given appop using shell commands
+ *
+ * <p>Expects caller to hold the shell permission identity.
+ */
+ public void setAppopPrivileged(int appop, boolean allow) {
+ final String opName = AppOpsManager.opToName(appop);
+ for (final String pkg : new String[] {"com.android.shell", mContext.getPackageName()}) {
+ final String cmd =
+ String.format(
+ "appops set %s %s %s",
+ pkg, // Package name
+ opName, // Appop
+ (allow ? "allow" : "deny")); // Action
+ SystemUtil.runShellCommand(cmd);
+ }
+ }
+
+ /** Sets up a test network using the provided interface name */
+ public TestNetworkCallback setupAndGetTestNetwork(String ifname) throws Exception {
+ // Build a network request
+ final NetworkRequest nr =
+ new NetworkRequest.Builder()
+ .clearCapabilities()
+ .addTransportType(TRANSPORT_TEST)
+ .setNetworkSpecifier(ifname)
+ .build();
+
+ final TestNetworkCallback cb = new TestNetworkCallback();
+ mCm.requestNetwork(nr, cb);
+
+ // Setup the test network after network request is filed to prevent Network from being
+ // reaped due to no requests matching it.
+ mContext.getSystemService(TestNetworkManager.class).setupTestNetwork(ifname, mBinder);
+
+ return cb;
+ }
+
+ // Toggle WiFi twice, leaving it in the state it started in
+ public void toggleWifi() {
+ if (mWifiManager.isWifiEnabled()) {
+ Network wifiNetwork = getWifiNetwork();
+ disconnectFromWifi(wifiNetwork);
+ connectToWifi();
+ } else {
+ connectToWifi();
+ Network wifiNetwork = getWifiNetwork();
+ disconnectFromWifi(wifiNetwork);
+ }
+ }
+
+ /**
+ * Enable WiFi and wait for it to become connected to a network.
+ *
+ * This method expects to receive a legacy broadcast on connect, which may not be sent if the
+ * network does not become default or if it is not the first network.
+ */
+ public Network connectToWifi() {
+ return connectToWifi(true /* expectLegacyBroadcast */);
+ }
+
+ /**
+ * Enable WiFi and wait for it to become connected to a network.
+ *
+ * A network is considered connected when a {@link NetworkRequest} with TRANSPORT_WIFI
+ * receives a {@link NetworkCallback#onAvailable(Network)} callback.
+ */
+ public Network ensureWifiConnected() {
+ return connectToWifi(false /* expectLegacyBroadcast */);
+ }
+
+ /**
+ * Enable WiFi and wait for it to become connected to a network.
+ *
+ * @param expectLegacyBroadcast Whether to check for a legacy CONNECTIVITY_ACTION connected
+ * broadcast. The broadcast is typically not sent if the network
+ * does not become the default network, and is not the first
+ * network to appear.
+ * @return The network that was newly connected.
+ */
+ private Network connectToWifi(boolean expectLegacyBroadcast) {
+ final TestNetworkCallback callback = new TestNetworkCallback();
+ mCm.registerNetworkCallback(makeWifiNetworkRequest(), callback);
+ Network wifiNetwork = null;
+
+ ConnectivityActionReceiver receiver = new ConnectivityActionReceiver(
+ mCm, ConnectivityManager.TYPE_WIFI, NetworkInfo.State.CONNECTED);
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(ConnectivityManager.CONNECTIVITY_ACTION);
+ mContext.registerReceiver(receiver, filter);
+
+ boolean connected = false;
+ final String err = "Wifi must be configured to connect to an access point for this test.";
+ try {
+ clearWifiBlacklist();
+ SystemUtil.runShellCommand("svc wifi enable");
+ final WifiConfiguration config = maybeAddVirtualWifiConfiguration();
+ if (config == null) {
+ // TODO: this may not clear the BSSID blacklist, as opposed to
+ // mWifiManager.connect(config)
+ assertTrue("Error reconnecting wifi", runAsShell(NETWORK_SETTINGS,
+ mWifiManager::reconnect));
+ } else {
+ // When running CTS, devices are expected to have wifi networks pre-configured.
+ // This condition is only hit on virtual devices.
+ final Integer error = runAsShell(NETWORK_SETTINGS, () -> {
+ final ConnectWifiListener listener = new ConnectWifiListener();
+ mWifiManager.connect(config, listener);
+ return listener.connectFuture.get(
+ CONNECTIVITY_CHANGE_TIMEOUT_SECS, TimeUnit.SECONDS);
+ });
+ assertNull("Error connecting to wifi: " + error, error);
+ }
+ // Ensure we get an onAvailable callback and possibly a CONNECTIVITY_ACTION.
+ wifiNetwork = callback.waitForAvailable();
+ assertNotNull(err, wifiNetwork);
+ connected = !expectLegacyBroadcast || receiver.waitForState();
+ } catch (InterruptedException ex) {
+ fail("connectToWifi was interrupted");
+ } finally {
+ mCm.unregisterNetworkCallback(callback);
+ mContext.unregisterReceiver(receiver);
+ }
+
+ assertTrue(err, connected);
+ return wifiNetwork;
+ }
+
+ private static class ConnectWifiListener implements WifiManager.ActionListener {
+ /**
+ * Future completed when the connect process ends. Provides the error code or null if none.
+ */
+ final CompletableFuture<Integer> connectFuture = new CompletableFuture<>();
+ @Override
+ public void onSuccess() {
+ connectFuture.complete(null);
+ }
+
+ @Override
+ public void onFailure(int reason) {
+ connectFuture.complete(reason);
+ }
+ }
+
+ private WifiConfiguration maybeAddVirtualWifiConfiguration() {
+ final List<WifiConfiguration> configs = runAsShell(NETWORK_SETTINGS,
+ mWifiManager::getConfiguredNetworks);
+ // If no network is configured, add a config for virtual access points if applicable
+ if (configs.size() == 0) {
+ final List<ScanResult> scanResults = getWifiScanResults();
+ final WifiConfiguration virtualConfig = maybeConfigureVirtualNetwork(scanResults);
+ assertNotNull("The device has no configured wifi network", virtualConfig);
+
+ return virtualConfig;
+ }
+ // No need to add a configuration: there is already one
+ return null;
+ }
+
+ private List<ScanResult> getWifiScanResults() {
+ final CompletableFuture<List<ScanResult>> scanResultsFuture = new CompletableFuture<>();
+ runAsShell(NETWORK_SETTINGS, () -> {
+ final BroadcastReceiver receiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ scanResultsFuture.complete(mWifiManager.getScanResults());
+ }
+ };
+ mContext.registerReceiver(receiver, new IntentFilter(SCAN_RESULTS_AVAILABLE_ACTION));
+ mWifiManager.startScan();
+ });
+
+ try {
+ return scanResultsFuture.get(CONNECTIVITY_CHANGE_TIMEOUT_SECS, TimeUnit.SECONDS);
+ } catch (ExecutionException | InterruptedException | TimeoutException e) {
+ throw new AssertionFailedError("Wifi scan results not received within timeout");
+ }
+ }
+
+ /**
+ * If a virtual wifi network is detected, add a configuration for that network.
+ * TODO(b/158150376): have the test infrastructure add virtual wifi networks when appropriate.
+ */
+ private WifiConfiguration maybeConfigureVirtualNetwork(List<ScanResult> scanResults) {
+ // Virtual wifi networks used on the emulator and cloud testing infrastructure
+ final List<String> virtualSsids = Arrays.asList("VirtWifi", "AndroidWifi");
+ Log.d(TAG, "Wifi scan results: " + scanResults);
+ final ScanResult virtualScanResult = scanResults.stream().filter(
+ s -> virtualSsids.contains(s.SSID)).findFirst().orElse(null);
+
+ // Only add the virtual configuration if the virtual AP is detected in scans
+ if (virtualScanResult == null) return null;
+
+ final WifiConfiguration virtualConfig = new WifiConfiguration();
+ // ASCII SSIDs need to be surrounded by double quotes
+ virtualConfig.SSID = "\"" + virtualScanResult.SSID + "\"";
+ virtualConfig.allowedKeyManagement.set(WifiConfiguration.KeyMgmt.NONE);
+
+ runAsShell(NETWORK_SETTINGS, () -> {
+ final int networkId = mWifiManager.addNetwork(virtualConfig);
+ assertTrue(networkId >= 0);
+ assertTrue(mWifiManager.enableNetwork(networkId, false /* attemptConnect */));
+ });
+ return virtualConfig;
+ }
+
+ /**
+ * Re-enable wifi networks that were blacklisted, typically because no internet connection was
+ * detected the last time they were connected. This is necessary to make sure wifi can reconnect
+ * to them.
+ */
+ private void clearWifiBlacklist() {
+ runAsShell(NETWORK_SETTINGS, () -> {
+ for (WifiConfiguration cfg : mWifiManager.getConfiguredNetworks()) {
+ assertTrue(mWifiManager.enableNetwork(cfg.networkId, false /* attemptConnect */));
+ }
+ });
+ }
+
+ /**
+ * Disable WiFi and wait for it to become disconnected from the network.
+ *
+ * This method expects to receive a legacy broadcast on disconnect, which may not be sent if the
+ * network was not default, or was not the first network.
+ *
+ * @param wifiNetworkToCheck If non-null, a network that should be disconnected. This network
+ * is expected to be able to establish a TCP connection to a remote
+ * server before disconnecting, and to have that connection closed in
+ * the process.
+ */
+ public void disconnectFromWifi(Network wifiNetworkToCheck) {
+ disconnectFromWifi(wifiNetworkToCheck, true /* expectLegacyBroadcast */);
+ }
+
+ /**
+ * Disable WiFi and wait for it to become disconnected from the network.
+ *
+ * @param wifiNetworkToCheck If non-null, a network that should be disconnected. This network
+ * is expected to be able to establish a TCP connection to a remote
+ * server before disconnecting, and to have that connection closed in
+ * the process.
+ */
+ public void ensureWifiDisconnected(Network wifiNetworkToCheck) {
+ disconnectFromWifi(wifiNetworkToCheck, false /* expectLegacyBroadcast */);
+ }
+
+ /**
+ * Disable WiFi and wait for it to become disconnected from the network.
+ *
+ * @param wifiNetworkToCheck If non-null, a network that should be disconnected. This network
+ * is expected to be able to establish a TCP connection to a remote
+ * server before disconnecting, and to have that connection closed in
+ * the process.
+ * @param expectLegacyBroadcast Whether to check for a legacy CONNECTIVITY_ACTION disconnected
+ * broadcast. The broadcast is typically not sent if the network
+ * was not the default network and not the first network to appear.
+ * The check will always be skipped if the device was not connected
+ * to wifi in the first place.
+ */
+ private void disconnectFromWifi(Network wifiNetworkToCheck, boolean expectLegacyBroadcast) {
+ final TestNetworkCallback callback = new TestNetworkCallback();
+ mCm.registerNetworkCallback(makeWifiNetworkRequest(), callback);
+
+ ConnectivityActionReceiver receiver = new ConnectivityActionReceiver(
+ mCm, ConnectivityManager.TYPE_WIFI, NetworkInfo.State.DISCONNECTED);
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(ConnectivityManager.CONNECTIVITY_ACTION);
+ mContext.registerReceiver(receiver, filter);
+
+ final WifiInfo wifiInfo = mWifiManager.getConnectionInfo();
+ final boolean wasWifiConnected = wifiInfo != null && wifiInfo.getNetworkId() != -1;
+ // Assert that we can establish a TCP connection on wifi.
+ Socket wifiBoundSocket = null;
+ if (wifiNetworkToCheck != null) {
+ assertTrue("Cannot check network " + wifiNetworkToCheck + ": wifi is not connected",
+ wasWifiConnected);
+ final NetworkCapabilities nc = mCm.getNetworkCapabilities(wifiNetworkToCheck);
+ assertNotNull("Network " + wifiNetworkToCheck + " is not connected", nc);
+ try {
+ wifiBoundSocket = getBoundSocket(wifiNetworkToCheck, TEST_HOST, HTTP_PORT);
+ testHttpRequest(wifiBoundSocket);
+ } catch (IOException e) {
+ fail("HTTP request before wifi disconnected failed with: " + e);
+ }
+ }
+
+ try {
+ SystemUtil.runShellCommand("svc wifi disable");
+ if (wasWifiConnected) {
+ // Ensure we get both an onLost callback and a CONNECTIVITY_ACTION.
+ assertNotNull("Did not receive onLost callback after disabling wifi",
+ callback.waitForLost());
+ }
+ if (wasWifiConnected && expectLegacyBroadcast) {
+ assertTrue("Wifi failed to reach DISCONNECTED state.", receiver.waitForState());
+ }
+ } catch (InterruptedException ex) {
+ fail("disconnectFromWifi was interrupted");
+ } finally {
+ mCm.unregisterNetworkCallback(callback);
+ mContext.unregisterReceiver(receiver);
+ }
+
+ // Check that the socket is closed when wifi disconnects.
+ if (wifiBoundSocket != null) {
+ try {
+ testHttpRequest(wifiBoundSocket);
+ fail("HTTP request should not succeed after wifi disconnects");
+ } catch (IOException expected) {
+ assertEquals(Os.strerror(OsConstants.ECONNABORTED), expected.getMessage());
+ }
+ }
+ }
+
+ public Network getWifiNetwork() {
+ TestNetworkCallback callback = new TestNetworkCallback();
+ mCm.registerNetworkCallback(makeWifiNetworkRequest(), callback);
+ Network network = null;
+ try {
+ network = callback.waitForAvailable();
+ } catch (InterruptedException e) {
+ fail("NetworkCallback wait was interrupted.");
+ } finally {
+ mCm.unregisterNetworkCallback(callback);
+ }
+ assertNotNull("Cannot find Network for wifi. Is wifi connected?", network);
+ return network;
+ }
+
+ public Network connectToCell() throws InterruptedException {
+ if (cellConnectAttempted()) {
+ throw new IllegalStateException("Already connected");
+ }
+ NetworkRequest cellRequest = new NetworkRequest.Builder()
+ .addTransportType(TRANSPORT_CELLULAR)
+ .addCapability(NET_CAPABILITY_INTERNET)
+ .build();
+ mCellNetworkCallback = new TestNetworkCallback();
+ mCm.requestNetwork(cellRequest, mCellNetworkCallback);
+ final Network cellNetwork = mCellNetworkCallback.waitForAvailable();
+ assertNotNull("Cell network not available. " +
+ "Please ensure the device has working mobile data.", cellNetwork);
+ return cellNetwork;
+ }
+
+ public void disconnectFromCell() {
+ if (!cellConnectAttempted()) {
+ throw new IllegalStateException("Cell connection not attempted");
+ }
+ mCm.unregisterNetworkCallback(mCellNetworkCallback);
+ mCellNetworkCallback = null;
+ }
+
+ public boolean cellConnectAttempted() {
+ return mCellNetworkCallback != null;
+ }
+
+ private NetworkRequest makeWifiNetworkRequest() {
+ return new NetworkRequest.Builder()
+ .addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
+ .build();
+ }
+
+ private void testHttpRequest(Socket s) throws IOException {
+ OutputStream out = s.getOutputStream();
+ InputStream in = s.getInputStream();
+
+ final byte[] requestBytes = HTTP_REQUEST.getBytes("UTF-8");
+ byte[] responseBytes = new byte[4096];
+ out.write(requestBytes);
+ in.read(responseBytes);
+ assertTrue(new String(responseBytes, "UTF-8").startsWith("HTTP/1.0 204 No Content\r\n"));
+ }
+
+ private Socket getBoundSocket(Network network, String host, int port) throws IOException {
+ InetSocketAddress addr = new InetSocketAddress(host, port);
+ Socket s = network.getSocketFactory().createSocket();
+ try {
+ s.setSoTimeout(SOCKET_TIMEOUT_MS);
+ s.connect(addr, SOCKET_TIMEOUT_MS);
+ } catch (IOException e) {
+ s.close();
+ throw e;
+ }
+ return s;
+ }
+
+ public void storePrivateDnsSetting() {
+ // Store private DNS setting
+ mOldPrivateDnsMode = Settings.Global.getString(mCR, Settings.Global.PRIVATE_DNS_MODE);
+ mOldPrivateDnsSpecifier = Settings.Global.getString(mCR,
+ Settings.Global.PRIVATE_DNS_SPECIFIER);
+ // It's possible that there is no private DNS default value in Settings.
+ // Give it a proper default mode which is opportunistic mode.
+ if (mOldPrivateDnsMode == null) {
+ mOldPrivateDnsSpecifier = "";
+ mOldPrivateDnsMode = PRIVATE_DNS_MODE_OPPORTUNISTIC;
+ Settings.Global.putString(mCR,
+ Settings.Global.PRIVATE_DNS_SPECIFIER, mOldPrivateDnsSpecifier);
+ Settings.Global.putString(mCR, Settings.Global.PRIVATE_DNS_MODE, mOldPrivateDnsMode);
+ }
+ }
+
+ public void restorePrivateDnsSetting() throws InterruptedException {
+ if (mOldPrivateDnsMode == null || mOldPrivateDnsSpecifier == null) {
+ return;
+ }
+ // restore private DNS setting
+ if ("hostname".equals(mOldPrivateDnsMode)) {
+ setPrivateDnsStrictMode(mOldPrivateDnsSpecifier);
+ awaitPrivateDnsSetting("restorePrivateDnsSetting timeout",
+ mCm.getActiveNetwork(),
+ mOldPrivateDnsSpecifier, true);
+ } else {
+ Settings.Global.putString(mCR, Settings.Global.PRIVATE_DNS_MODE, mOldPrivateDnsMode);
+ }
+ }
+
+ public void setPrivateDnsStrictMode(String server) {
+ // To reduce flake rate, set PRIVATE_DNS_SPECIFIER before PRIVATE_DNS_MODE. This ensures
+ // that if the previous private DNS mode was not "hostname", the system only sees one
+ // EVENT_PRIVATE_DNS_SETTINGS_CHANGED event instead of two.
+ Settings.Global.putString(mCR, Settings.Global.PRIVATE_DNS_SPECIFIER, server);
+ final String mode = Settings.Global.getString(mCR, Settings.Global.PRIVATE_DNS_MODE);
+ // If current private DNS mode is "hostname", we only need to set PRIVATE_DNS_SPECIFIER.
+ if (!"hostname".equals(mode)) {
+ Settings.Global.putString(mCR, Settings.Global.PRIVATE_DNS_MODE, "hostname");
+ }
+ }
+
+ public void awaitPrivateDnsSetting(@NonNull String msg, @NonNull Network network,
+ @NonNull String server, boolean requiresValidatedServers) throws InterruptedException {
+ CountDownLatch latch = new CountDownLatch(1);
+ NetworkRequest request = new NetworkRequest.Builder().clearCapabilities().build();
+ NetworkCallback callback = new NetworkCallback() {
+ @Override
+ public void onLinkPropertiesChanged(Network n, LinkProperties lp) {
+ if (requiresValidatedServers && lp.getValidatedPrivateDnsServers().isEmpty()) {
+ return;
+ }
+ if (network.equals(n) && server.equals(lp.getPrivateDnsServerName())) {
+ latch.countDown();
+ }
+ }
+ };
+ mCm.registerNetworkCallback(request, callback);
+ assertTrue(msg, latch.await(PRIVATE_DNS_SETTING_TIMEOUT_MS, TimeUnit.MILLISECONDS));
+ mCm.unregisterNetworkCallback(callback);
+ // Wait some time for NetworkMonitor's private DNS probe to complete. If we do not do
+ // this, then the test could complete before the NetworkMonitor private DNS probe
+ // completes. This would result in tearDown disabling private DNS, and the NetworkMonitor
+ // private DNS probe getting stuck because there are no longer any private DNS servers to
+ // query. This then results in the next test not being able to change the private DNS
+ // setting within the timeout, because the NetworkMonitor thread is blocked in the
+ // private DNS probe. There is no way to know when the probe has completed: because the
+ // network is likely already validated, there is no callback that we can listen to, so
+ // just sleep.
+ if (requiresValidatedServers) {
+ Thread.sleep(PRIVATE_DNS_PROBE_MS);
+ }
+ }
+
+ /**
+ * Receiver that captures the last connectivity change's network type and state. Recognizes
+ * both {@code CONNECTIVITY_ACTION} and {@code NETWORK_CALLBACK_ACTION} intents.
+ */
+ public static class ConnectivityActionReceiver extends BroadcastReceiver {
+
+ private final CountDownLatch mReceiveLatch = new CountDownLatch(1);
+
+ private final int mNetworkType;
+ private final NetworkInfo.State mNetState;
+ private final ConnectivityManager mCm;
+
+ public ConnectivityActionReceiver(ConnectivityManager cm, int networkType,
+ NetworkInfo.State netState) {
+ this.mCm = cm;
+ mNetworkType = networkType;
+ mNetState = netState;
+ }
+
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ NetworkInfo networkInfo = null;
+
+ // When receiving ConnectivityManager.CONNECTIVITY_ACTION, the NetworkInfo parcelable
+ // is stored in EXTRA_NETWORK_INFO. With a NETWORK_CALLBACK_ACTION, the Network is
+ // sent in EXTRA_NETWORK and we need to ask the ConnectivityManager for the NetworkInfo.
+ if (ConnectivityManager.CONNECTIVITY_ACTION.equals(action)) {
+ networkInfo = intent.getExtras()
+ .getParcelable(ConnectivityManager.EXTRA_NETWORK_INFO);
+ assertNotNull("ConnectivityActionReceiver expected EXTRA_NETWORK_INFO",
+ networkInfo);
+ } else if (NETWORK_CALLBACK_ACTION.equals(action)) {
+ Network network = intent.getExtras()
+ .getParcelable(ConnectivityManager.EXTRA_NETWORK);
+ assertNotNull("ConnectivityActionReceiver expected EXTRA_NETWORK", network);
+ networkInfo = this.mCm.getNetworkInfo(network);
+ if (networkInfo == null) {
+ // When disconnecting, it seems like we get an intent sent with an invalid
+ // Network; that is, by the time we call ConnectivityManager.getNetworkInfo(),
+ // it is invalid. Ignore these.
+ Log.i(TAG, "ConnectivityActionReceiver NETWORK_CALLBACK_ACTION ignoring "
+ + "invalid network");
+ return;
+ }
+ } else {
+ fail("ConnectivityActionReceiver received unxpected intent action: " + action);
+ }
+
+ assertNotNull("ConnectivityActionReceiver didn't find NetworkInfo", networkInfo);
+ int networkType = networkInfo.getType();
+ State networkState = networkInfo.getState();
+ Log.i(TAG, "Network type: " + networkType + " state: " + networkState);
+ if (networkType == mNetworkType && networkInfo.getState() == mNetState) {
+ mReceiveLatch.countDown();
+ }
+ }
+
+ public boolean waitForState() throws InterruptedException {
+ return mReceiveLatch.await(CONNECTIVITY_CHANGE_TIMEOUT_SECS, TimeUnit.SECONDS);
+ }
+ }
+
+ /**
+ * Callback used in testRegisterNetworkCallback that allows caller to block on
+ * {@code onAvailable}.
+ */
+ public static class TestNetworkCallback extends ConnectivityManager.NetworkCallback {
+ private final CountDownLatch mAvailableLatch = new CountDownLatch(1);
+ private final CountDownLatch mLostLatch = new CountDownLatch(1);
+ private final CountDownLatch mUnavailableLatch = new CountDownLatch(1);
+
+ public Network currentNetwork;
+ public Network lastLostNetwork;
+
+ public Network waitForAvailable() throws InterruptedException {
+ return mAvailableLatch.await(CONNECTIVITY_CHANGE_TIMEOUT_SECS, TimeUnit.SECONDS)
+ ? currentNetwork : null;
+ }
+
+ public Network waitForLost() throws InterruptedException {
+ return mLostLatch.await(CONNECTIVITY_CHANGE_TIMEOUT_SECS, TimeUnit.SECONDS)
+ ? lastLostNetwork : null;
+ }
+
+ public boolean waitForUnavailable() throws InterruptedException {
+ return mUnavailableLatch.await(2, TimeUnit.SECONDS);
+ }
+
+
+ @Override
+ public void onAvailable(Network network) {
+ currentNetwork = network;
+ mAvailableLatch.countDown();
+ }
+
+ @Override
+ public void onLost(Network network) {
+ lastLostNetwork = network;
+ if (network.equals(currentNetwork)) {
+ currentNetwork = null;
+ }
+ mLostLatch.countDown();
+ }
+
+ @Override
+ public void onUnavailable() {
+ mUnavailableLatch.countDown();
+ }
+ }
+}
diff --git a/tests/cts/net/util/java/android/net/cts/util/CtsTetheringUtils.java b/tests/cts/net/util/java/android/net/cts/util/CtsTetheringUtils.java
new file mode 100644
index 0000000..c95dc28
--- /dev/null
+++ b/tests/cts/net/util/java/android/net/cts/util/CtsTetheringUtils.java
@@ -0,0 +1,447 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.cts.util;
+
+import static android.net.TetheringManager.TETHERING_WIFI;
+import static android.net.TetheringManager.TETHER_ERROR_NO_ERROR;
+import static android.net.TetheringManager.TETHER_HARDWARE_OFFLOAD_FAILED;
+import static android.net.TetheringManager.TETHER_HARDWARE_OFFLOAD_STARTED;
+import static android.net.TetheringManager.TETHER_HARDWARE_OFFLOAD_STOPPED;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.junit.Assume.assumeTrue;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.PackageManager;
+import android.net.Network;
+import android.net.TetheredClient;
+import android.net.TetheringManager;
+import android.net.TetheringManager.TetheringEventCallback;
+import android.net.TetheringManager.TetheringInterfaceRegexps;
+import android.net.TetheringManager.TetheringRequest;
+import android.net.wifi.WifiClient;
+import android.net.wifi.WifiManager;
+import android.net.wifi.WifiManager.SoftApCallback;
+import android.os.ConditionVariable;
+
+import androidx.annotation.NonNull;
+
+import com.android.compatibility.common.util.SystemUtil;
+import com.android.net.module.util.ArrayTrackRecord;
+
+import java.util.Collection;
+import java.util.List;
+
+public final class CtsTetheringUtils {
+ private TetheringManager mTm;
+ private WifiManager mWm;
+ private Context mContext;
+
+ private static final int DEFAULT_TIMEOUT_MS = 60_000;
+
+ public CtsTetheringUtils(Context ctx) {
+ mContext = ctx;
+ mTm = mContext.getSystemService(TetheringManager.class);
+ mWm = mContext.getSystemService(WifiManager.class);
+ }
+
+ public static class StartTetheringCallback implements TetheringManager.StartTetheringCallback {
+ private static int TIMEOUT_MS = 30_000;
+ public static class CallbackValue {
+ public final int error;
+
+ private CallbackValue(final int e) {
+ error = e;
+ }
+
+ public static class OnTetheringStarted extends CallbackValue {
+ OnTetheringStarted() { super(TETHER_ERROR_NO_ERROR); }
+ }
+
+ public static class OnTetheringFailed extends CallbackValue {
+ OnTetheringFailed(final int error) { super(error); }
+ }
+
+ @Override
+ public String toString() {
+ return String.format("%s(%d)", getClass().getSimpleName(), error);
+ }
+ }
+
+ private final ArrayTrackRecord<CallbackValue>.ReadHead mHistory =
+ new ArrayTrackRecord<CallbackValue>().newReadHead();
+
+ @Override
+ public void onTetheringStarted() {
+ mHistory.add(new CallbackValue.OnTetheringStarted());
+ }
+
+ @Override
+ public void onTetheringFailed(final int error) {
+ mHistory.add(new CallbackValue.OnTetheringFailed(error));
+ }
+
+ public void verifyTetheringStarted() {
+ final CallbackValue cv = mHistory.poll(TIMEOUT_MS, c -> true);
+ assertNotNull("No onTetheringStarted after " + TIMEOUT_MS + " ms", cv);
+ assertTrue("Fail start tethering:" + cv,
+ cv instanceof CallbackValue.OnTetheringStarted);
+ }
+
+ public void expectTetheringFailed(final int expected) throws InterruptedException {
+ final CallbackValue cv = mHistory.poll(TIMEOUT_MS, c -> true);
+ assertNotNull("No onTetheringFailed after " + TIMEOUT_MS + " ms", cv);
+ assertTrue("Expect fail with error code " + expected + ", but received: " + cv,
+ (cv instanceof CallbackValue.OnTetheringFailed) && (cv.error == expected));
+ }
+ }
+
+ public static boolean isIfaceMatch(final List<String> ifaceRegexs, final List<String> ifaces) {
+ return isIfaceMatch(ifaceRegexs.toArray(new String[0]), ifaces);
+ }
+
+ public static boolean isIfaceMatch(final String[] ifaceRegexs, final List<String> ifaces) {
+ if (ifaceRegexs == null) fail("ifaceRegexs should not be null");
+
+ if (ifaces == null) return false;
+
+ for (String s : ifaces) {
+ for (String regex : ifaceRegexs) {
+ if (s.matches(regex)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ // Must poll the callback before looking at the member.
+ public static class TestTetheringEventCallback implements TetheringEventCallback {
+ private static final int TIMEOUT_MS = 30_000;
+
+ public enum CallbackType {
+ ON_SUPPORTED,
+ ON_UPSTREAM,
+ ON_TETHERABLE_REGEX,
+ ON_TETHERABLE_IFACES,
+ ON_TETHERED_IFACES,
+ ON_ERROR,
+ ON_CLIENTS,
+ ON_OFFLOAD_STATUS,
+ };
+
+ public static class CallbackValue {
+ public final CallbackType callbackType;
+ public final Object callbackParam;
+ public final int callbackParam2;
+
+ private CallbackValue(final CallbackType type, final Object param, final int param2) {
+ this.callbackType = type;
+ this.callbackParam = param;
+ this.callbackParam2 = param2;
+ }
+ }
+
+ private final ArrayTrackRecord<CallbackValue> mHistory =
+ new ArrayTrackRecord<CallbackValue>();
+
+ private final ArrayTrackRecord<CallbackValue>.ReadHead mCurrent =
+ mHistory.newReadHead();
+
+ private TetheringInterfaceRegexps mTetherableRegex;
+ private List<String> mTetherableIfaces;
+ private List<String> mTetheredIfaces;
+
+ @Override
+ public void onTetheringSupported(boolean supported) {
+ mHistory.add(new CallbackValue(CallbackType.ON_SUPPORTED, null, (supported ? 1 : 0)));
+ }
+
+ @Override
+ public void onUpstreamChanged(Network network) {
+ mHistory.add(new CallbackValue(CallbackType.ON_UPSTREAM, network, 0));
+ }
+
+ @Override
+ public void onTetherableInterfaceRegexpsChanged(TetheringInterfaceRegexps reg) {
+ mTetherableRegex = reg;
+ mHistory.add(new CallbackValue(CallbackType.ON_TETHERABLE_REGEX, reg, 0));
+ }
+
+ @Override
+ public void onTetherableInterfacesChanged(List<String> interfaces) {
+ mTetherableIfaces = interfaces;
+ mHistory.add(new CallbackValue(CallbackType.ON_TETHERABLE_IFACES, interfaces, 0));
+ }
+
+ @Override
+ public void onTetheredInterfacesChanged(List<String> interfaces) {
+ mTetheredIfaces = interfaces;
+ mHistory.add(new CallbackValue(CallbackType.ON_TETHERED_IFACES, interfaces, 0));
+ }
+
+ @Override
+ public void onError(String ifName, int error) {
+ mHistory.add(new CallbackValue(CallbackType.ON_ERROR, ifName, error));
+ }
+
+ @Override
+ public void onClientsChanged(Collection<TetheredClient> clients) {
+ mHistory.add(new CallbackValue(CallbackType.ON_CLIENTS, clients, 0));
+ }
+
+ @Override
+ public void onOffloadStatusChanged(int status) {
+ mHistory.add(new CallbackValue(CallbackType.ON_OFFLOAD_STATUS, status, 0));
+ }
+
+ public void expectTetherableInterfacesChanged(@NonNull List<String> regexs) {
+ assertNotNull("No expected tetherable ifaces callback", mCurrent.poll(TIMEOUT_MS,
+ (cv) -> {
+ if (cv.callbackType != CallbackType.ON_TETHERABLE_IFACES) return false;
+ final List<String> interfaces = (List<String>) cv.callbackParam;
+ return isIfaceMatch(regexs, interfaces);
+ }));
+ }
+
+ public void expectTetheredInterfacesChanged(@NonNull List<String> regexs) {
+ assertNotNull("No expected tethered ifaces callback", mCurrent.poll(TIMEOUT_MS,
+ (cv) -> {
+ if (cv.callbackType != CallbackType.ON_TETHERED_IFACES) return false;
+
+ final List<String> interfaces = (List<String>) cv.callbackParam;
+
+ // Null regexs means no active tethering.
+ if (regexs == null) return interfaces.isEmpty();
+
+ return isIfaceMatch(regexs, interfaces);
+ }));
+ }
+
+ public void expectCallbackStarted() {
+ int receivedBitMap = 0;
+ // The each bit represent a type from CallbackType.ON_*.
+ // Expect all of callbacks except for ON_ERROR.
+ final int expectedBitMap = 0xff ^ (1 << CallbackType.ON_ERROR.ordinal());
+ // Receive ON_ERROR on started callback is not matter. It just means tethering is
+ // failed last time, should able to continue the test this time.
+ while ((receivedBitMap & expectedBitMap) != expectedBitMap) {
+ final CallbackValue cv = mCurrent.poll(TIMEOUT_MS, c -> true);
+ if (cv == null) {
+ fail("No expected callbacks, " + "expected bitmap: "
+ + expectedBitMap + ", actual: " + receivedBitMap);
+ }
+
+ receivedBitMap |= (1 << cv.callbackType.ordinal());
+ }
+ }
+
+ public void expectOneOfOffloadStatusChanged(int... offloadStatuses) {
+ assertNotNull("No offload status changed", mCurrent.poll(TIMEOUT_MS, (cv) -> {
+ if (cv.callbackType != CallbackType.ON_OFFLOAD_STATUS) return false;
+
+ final int status = (int) cv.callbackParam;
+ for (int offloadStatus : offloadStatuses) {
+ if (offloadStatus == status) return true;
+ }
+
+ return false;
+ }));
+ }
+
+ public void expectErrorOrTethered(final String iface) {
+ assertNotNull("No expected callback", mCurrent.poll(TIMEOUT_MS, (cv) -> {
+ if (cv.callbackType == CallbackType.ON_ERROR
+ && iface.equals((String) cv.callbackParam)) {
+ return true;
+ }
+ if (cv.callbackType == CallbackType.ON_TETHERED_IFACES
+ && ((List<String>) cv.callbackParam).contains(iface)) {
+ return true;
+ }
+
+ return false;
+ }));
+ }
+
+ public Network getCurrentValidUpstream() {
+ final CallbackValue result = mCurrent.poll(TIMEOUT_MS, (cv) -> {
+ return (cv.callbackType == CallbackType.ON_UPSTREAM)
+ && cv.callbackParam != null;
+ });
+
+ assertNotNull("No valid upstream", result);
+ return (Network) result.callbackParam;
+ }
+
+ public void assumeTetheringSupported() {
+ final ArrayTrackRecord<CallbackValue>.ReadHead history =
+ mHistory.newReadHead();
+ assertNotNull("No onSupported callback", history.poll(TIMEOUT_MS, (cv) -> {
+ if (cv.callbackType != CallbackType.ON_SUPPORTED) return false;
+
+ assumeTrue(cv.callbackParam2 == 1 /* supported */);
+ return true;
+ }));
+ }
+
+ public void assumeWifiTetheringSupported(final Context ctx) throws Exception {
+ assumeTetheringSupported();
+
+ assumeTrue(!getTetheringInterfaceRegexps().getTetherableWifiRegexs().isEmpty());
+
+ final PackageManager pm = ctx.getPackageManager();
+ assumeTrue(pm.hasSystemFeature(PackageManager.FEATURE_WIFI));
+
+ WifiManager wm = ctx.getSystemService(WifiManager.class);
+ // Wifi feature flags only work when wifi is on.
+ final boolean previousWifiEnabledState = wm.isWifiEnabled();
+ try {
+ if (!previousWifiEnabledState) SystemUtil.runShellCommand("svc wifi enable");
+ waitForWifiEnabled(ctx);
+ assumeTrue(wm.isPortableHotspotSupported());
+ } finally {
+ if (!previousWifiEnabledState) SystemUtil.runShellCommand("svc wifi disable");
+ }
+ }
+
+ public TetheringInterfaceRegexps getTetheringInterfaceRegexps() {
+ return mTetherableRegex;
+ }
+
+ public List<String> getTetherableInterfaces() {
+ return mTetherableIfaces;
+ }
+
+ public List<String> getTetheredInterfaces() {
+ return mTetheredIfaces;
+ }
+ }
+
+ private static void waitForWifiEnabled(final Context ctx) throws Exception {
+ WifiManager wm = ctx.getSystemService(WifiManager.class);
+ if (wm.isWifiEnabled()) return;
+
+ final ConditionVariable mWaiting = new ConditionVariable();
+ final BroadcastReceiver receiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ if (WifiManager.WIFI_STATE_CHANGED_ACTION.equals(action)) {
+ if (wm.isWifiEnabled()) mWaiting.open();
+ }
+ }
+ };
+ try {
+ ctx.registerReceiver(receiver, new IntentFilter(WifiManager.WIFI_STATE_CHANGED_ACTION));
+ if (!mWaiting.block(DEFAULT_TIMEOUT_MS)) {
+ assertTrue("Wifi did not become enabled after " + DEFAULT_TIMEOUT_MS + "ms",
+ wm.isWifiEnabled());
+ }
+ } finally {
+ ctx.unregisterReceiver(receiver);
+ }
+ }
+
+ public TestTetheringEventCallback registerTetheringEventCallback() {
+ final TestTetheringEventCallback tetherEventCallback =
+ new TestTetheringEventCallback();
+
+ mTm.registerTetheringEventCallback(c -> c.run() /* executor */, tetherEventCallback);
+ tetherEventCallback.expectCallbackStarted();
+
+ return tetherEventCallback;
+ }
+
+ public void unregisterTetheringEventCallback(final TestTetheringEventCallback callback) {
+ mTm.unregisterTetheringEventCallback(callback);
+ }
+
+ private static List<String> getWifiTetherableInterfaceRegexps(
+ final TestTetheringEventCallback callback) {
+ return callback.getTetheringInterfaceRegexps().getTetherableWifiRegexs();
+ }
+
+ public static boolean isWifiTetheringSupported(final TestTetheringEventCallback callback) {
+ return !getWifiTetherableInterfaceRegexps(callback).isEmpty();
+ }
+
+ public void startWifiTethering(final TestTetheringEventCallback callback)
+ throws InterruptedException {
+ final List<String> wifiRegexs = getWifiTetherableInterfaceRegexps(callback);
+ assertFalse(isIfaceMatch(wifiRegexs, callback.getTetheredInterfaces()));
+
+ final StartTetheringCallback startTetheringCallback = new StartTetheringCallback();
+ final TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI)
+ .setShouldShowEntitlementUi(false).build();
+ mTm.startTethering(request, c -> c.run() /* executor */, startTetheringCallback);
+ startTetheringCallback.verifyTetheringStarted();
+
+ callback.expectTetheredInterfacesChanged(wifiRegexs);
+
+ callback.expectOneOfOffloadStatusChanged(
+ TETHER_HARDWARE_OFFLOAD_STARTED,
+ TETHER_HARDWARE_OFFLOAD_FAILED);
+ }
+
+ private static class StopSoftApCallback implements SoftApCallback {
+ private final ConditionVariable mWaiting = new ConditionVariable();
+ @Override
+ public void onStateChanged(int state, int failureReason) {
+ if (state == WifiManager.WIFI_AP_STATE_DISABLED) mWaiting.open();
+ }
+
+ @Override
+ public void onConnectedClientsChanged(List<WifiClient> clients) { }
+
+ public void waitForSoftApStopped() {
+ if (!mWaiting.block(DEFAULT_TIMEOUT_MS)) {
+ fail("stopSoftAp Timeout");
+ }
+ }
+ }
+
+ // Wait for softAp to be disabled. This is necessary on devices where stopping softAp
+ // deletes the interface. On these devices, tethering immediately stops when the softAp
+ // interface is removed, but softAp is not yet fully disabled. Wait for softAp to be
+ // fully disabled, because otherwise the next test might fail because it attempts to
+ // start softAp before it's fully stopped.
+ public void expectSoftApDisabled() {
+ final StopSoftApCallback callback = new StopSoftApCallback();
+ try {
+ mWm.registerSoftApCallback(c -> c.run(), callback);
+ // registerSoftApCallback will immediately call the callback with the current state, so
+ // this callback will fire even if softAp is already disabled.
+ callback.waitForSoftApStopped();
+ } finally {
+ mWm.unregisterSoftApCallback(callback);
+ }
+ }
+
+ public void stopWifiTethering(final TestTetheringEventCallback callback) {
+ mTm.stopTethering(TETHERING_WIFI);
+ expectSoftApDisabled();
+ callback.expectTetheredInterfacesChanged(null);
+ callback.expectOneOfOffloadStatusChanged(TETHER_HARDWARE_OFFLOAD_STOPPED);
+ }
+}