diff --git a/staticlibs/testutils/devicetests/com/android/testutils/ConnectUtil.kt b/staticlibs/testutils/devicetests/com/android/testutils/ConnectUtil.kt index b1d64f865a..8090d5b6e1 100644 --- a/staticlibs/testutils/devicetests/com/android/testutils/ConnectUtil.kt +++ b/staticlibs/testutils/devicetests/com/android/testutils/ConnectUtil.kt @@ -116,7 +116,10 @@ class ConnectUtil(private val context: Context) { } } - private fun connectToWifiConfig(config: WifiConfiguration) { + // Suppress warning because WifiManager methods to connect to a config are + // documented not to be deprecated for privileged users. + @Suppress("DEPRECATION") + fun connectToWifiConfig(config: WifiConfiguration) { repeat(MAX_WIFI_CONNECT_RETRIES) { val error = runAsShell(permission.NETWORK_SETTINGS) { val listener = ConnectWifiListener() diff --git a/tests/cts/multidevices/Android.bp b/tests/cts/multidevices/Android.bp new file mode 100644 index 0000000000..5ac4229649 --- /dev/null +++ b/tests/cts/multidevices/Android.bp @@ -0,0 +1,42 @@ +// Copyright (C) 2024 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 { + default_applicable_licenses: ["Android-Apache-2.0"], +} + +python_test_host { + name: "CtsConnectivityMultiDevicesTestCases", + main: "connectivity_multi_devices_test.py", + srcs: ["connectivity_multi_devices_test.py"], + libs: [ + "mobly", + ], + test_suites: [ + "cts", + "general-tests", + ], + test_options: { + unit_test: false, + }, + data: [ + // Package the snippet with the mobly test + ":connectivity_multi_devices_snippet", + ], + version: { + py3: { + embedded_launcher: true, + }, + }, +} diff --git a/tests/cts/multidevices/AndroidTest.xml b/tests/cts/multidevices/AndroidTest.xml new file mode 100644 index 0000000000..5312b4de05 --- /dev/null +++ b/tests/cts/multidevices/AndroidTest.xml @@ -0,0 +1,43 @@ + + + + + diff --git a/tests/cts/multidevices/connectivity_multi_devices_test.py b/tests/cts/multidevices/connectivity_multi_devices_test.py new file mode 100644 index 0000000000..ab88504994 --- /dev/null +++ b/tests/cts/multidevices/connectivity_multi_devices_test.py @@ -0,0 +1,110 @@ +# Lint as: python3 +"""Connectivity multi devices tests.""" +import base64 +import sys +import uuid + +from mobly import asserts +from mobly import base_test +from mobly import test_runner +from mobly import utils +from mobly.controllers import android_device + +CONNECTIVITY_MULTI_DEVICES_SNIPPET_PACKAGE = "com.google.snippet.connectivity" + + +class UpstreamType: + CELLULAR = 1 + WIFI = 2 + + +class ConnectivityMultiDevicesTest(base_test.BaseTestClass): + + def setup_class(self): + # Declare that two Android devices are needed. + self.clientDevice, self.serverDevice = self.register_controller( + android_device, min_number=2 + ) + + def setup_device(device): + device.load_snippet( + "connectivity_multi_devices_snippet", + CONNECTIVITY_MULTI_DEVICES_SNIPPET_PACKAGE, + ) + + # Set up devices in parallel to save time. + utils.concurrent_exec( + setup_device, + ((self.clientDevice,), (self.serverDevice,)), + max_workers=2, + raise_on_exception=True, + ) + + @staticmethod + def generate_uuid32_base64(): + """Generates a UUID32 and encodes it in Base64. + + Returns: + str: The Base64-encoded UUID32 string. Which is 22 characters. + """ + return base64.b64encode(uuid.uuid1().bytes).decode("utf-8").strip("=") + + def _do_test_hotspot_for_upstream_type(self, upstream_type): + """Test hotspot with the specified upstream type. + + This test create a hotspot, make the client connect + to it, and verify the packet is forwarded by the hotspot. + """ + server = self.serverDevice.connectivity_multi_devices_snippet + client = self.clientDevice.connectivity_multi_devices_snippet + + # Assert pre-conditions specific to each upstream type. + asserts.skip_if(not client.hasWifiFeature(), "Client requires Wifi feature") + asserts.skip_if( + not server.hasHotspotFeature(), "Server requires hotspot feature" + ) + if upstream_type == UpstreamType.CELLULAR: + asserts.skip_if( + not server.hasTelephonyFeature(), "Server requires Telephony feature" + ) + server.requestCellularAndEnsureDefault() + elif upstream_type == UpstreamType.WIFI: + asserts.skip_if( + not server.isStaApConcurrencySupported(), + "Server requires Wifi AP + STA concurrency", + ) + server.ensureWifiIsDefault() + else: + raise ValueError(f"Invalid upstream type: {upstream_type}") + + # Generate ssid/passphrase with random characters to make sure nearby devices won't + # connect unexpectedly. Note that total length of ssid cannot go over 32. + testSsid = "HOTSPOT-" + self.generate_uuid32_base64() + testPassphrase = self.generate_uuid32_base64() + + try: + # Create a hotspot with fixed SSID and password. + server.startHotspot(testSsid, testPassphrase) + + # Make the client connects to the hotspot. + client.connectToWifi(testSsid, testPassphrase, True) + + finally: + if upstream_type == UpstreamType.CELLULAR: + server.unrequestCellular() + # Teardown the hotspot. + server.stopAllTethering() + + def test_hotspot_upstream_wifi(self): + self._do_test_hotspot_for_upstream_type(UpstreamType.WIFI) + + def test_hotspot_upstream_cellular(self): + self._do_test_hotspot_for_upstream_type(UpstreamType.CELLULAR) + + +if __name__ == "__main__": + # Take test args + if "--" in sys.argv: + index = sys.argv.index("--") + sys.argv = sys.argv[:1] + sys.argv[index + 1 :] + test_runner.main() diff --git a/tests/cts/multidevices/snippet/Android.bp b/tests/cts/multidevices/snippet/Android.bp new file mode 100644 index 0000000000..5940cbb0b1 --- /dev/null +++ b/tests/cts/multidevices/snippet/Android.bp @@ -0,0 +1,37 @@ +// Copyright (C) 2024 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 { + default_applicable_licenses: ["Android-Apache-2.0"], +} + +android_test_helper_app { + name: "connectivity_multi_devices_snippet", + defaults: [ + "ConnectivityTestsLatestSdkDefaults", + "cts_defaults", + "framework-connectivity-test-defaults", + ], + srcs: [ + "ConnectivityMultiDevicesSnippet.kt", + ], + manifest: "AndroidManifest.xml", + static_libs: [ + "androidx.test.runner", + "mobly-snippet-lib", + "cts-net-utils", + ], + platform_apis: true, + min_sdk_version: "30", // R +} diff --git a/tests/cts/multidevices/snippet/AndroidManifest.xml b/tests/cts/multidevices/snippet/AndroidManifest.xml new file mode 100644 index 0000000000..9ed81469f3 --- /dev/null +++ b/tests/cts/multidevices/snippet/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + diff --git a/tests/cts/multidevices/snippet/ConnectivityMultiDevicesSnippet.kt b/tests/cts/multidevices/snippet/ConnectivityMultiDevicesSnippet.kt new file mode 100644 index 0000000000..115210b405 --- /dev/null +++ b/tests/cts/multidevices/snippet/ConnectivityMultiDevicesSnippet.kt @@ -0,0 +1,170 @@ +/* + * Copyright (C) 2024 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 com.google.snippet.connectivity + +import android.Manifest.permission.OVERRIDE_WIFI_CONFIG +import android.content.pm.PackageManager.FEATURE_TELEPHONY +import android.content.pm.PackageManager.FEATURE_WIFI +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED +import android.net.NetworkCapabilities.TRANSPORT_WIFI +import android.net.NetworkRequest +import android.net.cts.util.CtsNetUtils +import android.net.cts.util.CtsTetheringUtils +import android.net.wifi.ScanResult +import android.net.wifi.SoftApConfiguration +import android.net.wifi.SoftApConfiguration.SECURITY_TYPE_WPA2_PSK +import android.net.wifi.WifiConfiguration +import android.net.wifi.WifiManager +import android.net.wifi.WifiNetworkSpecifier +import android.net.wifi.WifiSsid +import androidx.test.platform.app.InstrumentationRegistry +import com.android.testutils.ConnectUtil +import com.android.testutils.RecorderCallback.CallbackEntry.Available +import com.android.testutils.RecorderCallback.CallbackEntry.CapabilitiesChanged +import com.android.testutils.TestableNetworkCallback +import com.android.testutils.runAsShell +import com.google.android.mobly.snippet.Snippet +import com.google.android.mobly.snippet.rpc.Rpc + +class ConnectivityMultiDevicesSnippet : Snippet { + private val context = InstrumentationRegistry.getInstrumentation().getTargetContext() + private val wifiManager = context.getSystemService(WifiManager::class.java)!! + private val cm = context.getSystemService(ConnectivityManager::class.java)!! + private val pm = context.packageManager + private val ctsNetUtils = CtsNetUtils(context) + private val ctsTetheringUtils = CtsTetheringUtils(context) + private var oldSoftApConfig: SoftApConfiguration? = null + + @Rpc(description = "Check whether the device has wifi feature.") + fun hasWifiFeature() = pm.hasSystemFeature(FEATURE_WIFI) + + @Rpc(description = "Check whether the device has telephony feature.") + fun hasTelephonyFeature() = pm.hasSystemFeature(FEATURE_TELEPHONY) + + @Rpc(description = "Check whether the device supporters AP + STA concurrency.") + fun isStaApConcurrencySupported() { + wifiManager.isStaApConcurrencySupported() + } + + @Rpc(description = "Request cellular connection and ensure it is the default network.") + fun requestCellularAndEnsureDefault() { + ctsNetUtils.disableWifi() + val network = ctsNetUtils.connectToCell() + ctsNetUtils.expectNetworkIsSystemDefault(network) + } + + @Rpc(description = "Unrequest cellular connection.") + fun unrequestCellular() { + ctsNetUtils.disconnectFromCell() + } + + @Rpc(description = "Ensure any wifi is connected and is the default network.") + fun ensureWifiIsDefault() { + val network = ctsNetUtils.ensureWifiConnected() + ctsNetUtils.expectNetworkIsSystemDefault(network) + } + + @Rpc(description = "Connect to specified wifi network.") + // Suppress warning because WifiManager methods to connect to a config are + // documented not to be deprecated for privileged users. + @Suppress("DEPRECATION") + fun connectToWifi(ssid: String, passphrase: String, requireValidation: Boolean): Network { + val specifier = WifiNetworkSpecifier.Builder() + .setSsid(ssid) + .setWpa2Passphrase(passphrase) + .setBand(ScanResult.WIFI_BAND_24_GHZ) + .build() + val wifiConfig = WifiConfiguration() + wifiConfig.SSID = "\"" + ssid + "\"" + wifiConfig.preSharedKey = "\"" + passphrase + "\"" + wifiConfig.hiddenSSID = true + wifiConfig.allowedKeyManagement.set(WifiConfiguration.KeyMgmt.WPA2_PSK) + wifiConfig.allowedPairwiseCiphers.set(WifiConfiguration.PairwiseCipher.TKIP) + wifiConfig.allowedPairwiseCiphers.set(WifiConfiguration.PairwiseCipher.CCMP) + + // Register network callback for the specific wifi. + val networkCallback = TestableNetworkCallback() + val wifiRequest = NetworkRequest.Builder().addTransportType(TRANSPORT_WIFI) + .setNetworkSpecifier(specifier) + .build() + cm.registerNetworkCallback(wifiRequest, networkCallback) + + try { + // Add the test configuration and connect to it. + val connectUtil = ConnectUtil(context) + connectUtil.connectToWifiConfig(wifiConfig) + + val event = networkCallback.expect() + if (requireValidation) { + networkCallback.eventuallyExpect { + it.caps.hasCapability(NET_CAPABILITY_VALIDATED) + } + } + return event.network + } finally { + cm.unregisterNetworkCallback(networkCallback) + } + } + + @Rpc(description = "Check whether the device supports hotspot feature.") + fun hasHotspotFeature(): Boolean { + val tetheringCallback = ctsTetheringUtils.registerTetheringEventCallback() + try { + return tetheringCallback.isWifiTetheringSupported(context) + } finally { + ctsTetheringUtils.unregisterTetheringEventCallback(tetheringCallback) + } + } + + @Rpc(description = "Start a hotspot with given SSID and passphrase.") + fun startHotspot(ssid: String, passphrase: String) { + // Store old config. + runAsShell(OVERRIDE_WIFI_CONFIG) { + oldSoftApConfig = wifiManager.getSoftApConfiguration() + } + + val softApConfig = SoftApConfiguration.Builder() + .setWifiSsid(WifiSsid.fromBytes(ssid.toByteArray())) + .setPassphrase(passphrase, SECURITY_TYPE_WPA2_PSK) + .setBand(SoftApConfiguration.BAND_2GHZ) + .build() + runAsShell(OVERRIDE_WIFI_CONFIG) { + wifiManager.setSoftApConfiguration(softApConfig) + } + val tetheringCallback = ctsTetheringUtils.registerTetheringEventCallback() + try { + tetheringCallback.expectNoTetheringActive() + ctsTetheringUtils.startWifiTethering(tetheringCallback) + } finally { + ctsTetheringUtils.unregisterTetheringEventCallback(tetheringCallback) + } + } + + @Rpc(description = "Stop all tethering.") + fun stopAllTethering() { + ctsTetheringUtils.stopAllTethering() + + // Restore old config. + oldSoftApConfig?.let { + runAsShell(OVERRIDE_WIFI_CONFIG) { + wifiManager.setSoftApConfiguration(it) + } + } + } +}