1 /* 2 * Copyright (C) 2020 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package android.net.cts 18 19 import android.Manifest.permission.CONNECTIVITY_INTERNAL 20 import android.Manifest.permission.NETWORK_SETTINGS 21 import android.Manifest.permission.READ_DEVICE_CONFIG 22 import android.content.pm.PackageManager.FEATURE_TELEPHONY 23 import android.content.pm.PackageManager.FEATURE_WATCH 24 import android.content.pm.PackageManager.FEATURE_WIFI 25 import android.net.ConnectivityManager 26 import android.net.ConnectivityManager.NetworkCallback 27 import android.net.Network 28 import android.net.NetworkCapabilities 29 import android.net.NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL 30 import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET 31 import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED 32 import android.net.NetworkCapabilities.TRANSPORT_CELLULAR 33 import android.net.NetworkCapabilities.TRANSPORT_WIFI 34 import android.net.NetworkRequest 35 import android.net.Uri 36 import android.net.cts.NetworkValidationTestUtil.clearValidationTestUrlsDeviceConfig 37 import android.net.cts.NetworkValidationTestUtil.setHttpUrlDeviceConfig 38 import android.net.cts.NetworkValidationTestUtil.setHttpsUrlDeviceConfig 39 import android.net.cts.NetworkValidationTestUtil.setUrlExpirationDeviceConfig 40 import android.net.cts.util.CtsNetUtils 41 import android.net.util.NetworkStackUtils.TEST_CAPTIVE_PORTAL_HTTPS_URL 42 import android.net.util.NetworkStackUtils.TEST_CAPTIVE_PORTAL_HTTP_URL 43 import android.net.wifi.WifiManager 44 import android.os.Build 45 import android.platform.test.annotations.AppModeFull 46 import android.provider.DeviceConfig 47 import android.provider.DeviceConfig.NAMESPACE_CONNECTIVITY 48 import android.text.TextUtils 49 import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation 50 import androidx.test.runner.AndroidJUnit4 51 import com.android.testutils.RecorderCallback 52 import com.android.testutils.TestHttpServer 53 import com.android.testutils.TestHttpServer.Request 54 import com.android.testutils.TestableNetworkCallback 55 import com.android.testutils.isDevSdkInRange 56 import com.android.testutils.runAsShell 57 import fi.iki.elonen.NanoHTTPD.Response.Status 58 import junit.framework.AssertionFailedError 59 import org.junit.After 60 import org.junit.Assume.assumeTrue 61 import org.junit.Assume.assumeFalse 62 import org.junit.Before 63 import org.junit.runner.RunWith 64 import java.util.concurrent.CompletableFuture 65 import java.util.concurrent.TimeUnit 66 import java.util.concurrent.TimeoutException 67 import kotlin.test.Test 68 import kotlin.test.assertNotEquals 69 import kotlin.test.assertNotNull 70 import kotlin.test.assertTrue 71 72 private const val TEST_HTTPS_URL_PATH = "/https_path" 73 private const val TEST_HTTP_URL_PATH = "/http_path" 74 private const val TEST_PORTAL_URL_PATH = "/portal_path" 75 76 private const val LOCALHOST_HOSTNAME = "localhost" 77 78 // Re-connecting to the AP, obtaining an IP address, revalidating can take a long time 79 private const val WIFI_CONNECT_TIMEOUT_MS = 120_000L 80 private const val TEST_TIMEOUT_MS = 10_000L 81 82 private fun <T> CompletableFuture<T>.assertGet(timeoutMs: Long, message: String): T { 83 try { 84 return get(timeoutMs, TimeUnit.MILLISECONDS) 85 } catch (e: TimeoutException) { 86 throw AssertionFailedError(message) 87 } 88 } 89 90 @AppModeFull(reason = "WRITE_DEVICE_CONFIG permission can't be granted to instant apps") 91 @RunWith(AndroidJUnit4::class) 92 class CaptivePortalTest { 93 private val context: android.content.Context by lazy { getInstrumentation().context } 94 private val wm by lazy { context.getSystemService(WifiManager::class.java) } 95 private val cm by lazy { context.getSystemService(ConnectivityManager::class.java) } 96 private val pm by lazy { context.packageManager } 97 private val utils by lazy { CtsNetUtils(context) } 98 99 private val server = TestHttpServer("localhost") 100 101 @Before 102 fun setUp() { 103 runAsShell(READ_DEVICE_CONFIG) { 104 // Verify that the test URLs are not normally set on the device, but do not fail if the 105 // test URLs are set to what this test uses (URLs on localhost), in case the test was 106 // interrupted manually and rerun. 107 assertEmptyOrLocalhostUrl(TEST_CAPTIVE_PORTAL_HTTPS_URL) 108 assertEmptyOrLocalhostUrl(TEST_CAPTIVE_PORTAL_HTTP_URL) 109 } 110 clearValidationTestUrlsDeviceConfig() 111 server.start() 112 } 113 114 @After 115 fun tearDown() { 116 clearValidationTestUrlsDeviceConfig() 117 if (pm.hasSystemFeature(FEATURE_WIFI)) { 118 reconnectWifi() 119 } 120 server.stop() 121 } 122 123 private fun assertEmptyOrLocalhostUrl(urlKey: String) { 124 val url = DeviceConfig.getProperty(NAMESPACE_CONNECTIVITY, urlKey) 125 assertTrue(TextUtils.isEmpty(url) || LOCALHOST_HOSTNAME == Uri.parse(url).host, 126 "$urlKey must not be set in production scenarios (current value: $url)") 127 } 128 129 @Test 130 fun testCaptivePortalIsNotDefaultNetwork() { 131 assumeTrue(pm.hasSystemFeature(FEATURE_TELEPHONY)) 132 assumeTrue(pm.hasSystemFeature(FEATURE_WIFI)) 133 assumeFalse(pm.hasSystemFeature(FEATURE_WATCH)) 134 utils.ensureWifiConnected() 135 val cellNetwork = utils.connectToCell() 136 137 // Verify cell network is validated 138 val cellReq = NetworkRequest.Builder() 139 .addTransportType(TRANSPORT_CELLULAR) 140 .addCapability(NET_CAPABILITY_INTERNET) 141 .build() 142 val cellCb = TestableNetworkCallback(timeoutMs = TEST_TIMEOUT_MS) 143 cm.registerNetworkCallback(cellReq, cellCb) 144 val cb = cellCb.eventuallyExpectOrNull<RecorderCallback.CallbackEntry.CapabilitiesChanged> { 145 it.network == cellNetwork && it.caps.hasCapability(NET_CAPABILITY_VALIDATED) 146 } 147 assertNotNull(cb, "Mobile network $cellNetwork has no access to the internet. " + 148 "Check the mobile data connection.") 149 150 // Have network validation use a local server that serves a HTTPS error / HTTP redirect 151 server.addResponse(Request(TEST_PORTAL_URL_PATH), Status.OK, 152 content = "Test captive portal content") 153 server.addResponse(Request(TEST_HTTPS_URL_PATH), Status.INTERNAL_ERROR) 154 val headers = mapOf("Location" to makeUrl(TEST_PORTAL_URL_PATH)) 155 server.addResponse(Request(TEST_HTTP_URL_PATH), Status.REDIRECT, headers) 156 setHttpsUrlDeviceConfig(makeUrl(TEST_HTTPS_URL_PATH)) 157 setHttpUrlDeviceConfig(makeUrl(TEST_HTTP_URL_PATH)) 158 // URL expiration needs to be in the next 10 minutes 159 assertTrue(WIFI_CONNECT_TIMEOUT_MS < TimeUnit.MINUTES.toMillis(10)) 160 setUrlExpirationDeviceConfig(System.currentTimeMillis() + WIFI_CONNECT_TIMEOUT_MS) 161 162 // Wait for a captive portal to be detected on the network 163 val wifiNetworkFuture = CompletableFuture<Network>() 164 val wifiCb = object : NetworkCallback() { 165 override fun onCapabilitiesChanged( 166 network: Network, 167 nc: NetworkCapabilities 168 ) { 169 if (nc.hasCapability(NET_CAPABILITY_CAPTIVE_PORTAL)) { 170 wifiNetworkFuture.complete(network) 171 } 172 } 173 } 174 cm.requestNetwork(NetworkRequest.Builder().addTransportType(TRANSPORT_WIFI).build(), wifiCb) 175 176 try { 177 reconnectWifi() 178 val network = wifiNetworkFuture.assertGet(WIFI_CONNECT_TIMEOUT_MS, 179 "Captive portal not detected after ${WIFI_CONNECT_TIMEOUT_MS}ms") 180 181 val wifiDefaultMessage = "Wifi should not be the default network when a captive " + 182 "portal was detected and another network (mobile data) can provide internet " + 183 "access." 184 assertNotEquals(network, cm.activeNetwork, wifiDefaultMessage) 185 186 val startPortalAppPermission = 187 if (isDevSdkInRange(0, Build.VERSION_CODES.Q)) CONNECTIVITY_INTERNAL 188 else NETWORK_SETTINGS 189 runAsShell(startPortalAppPermission) { cm.startCaptivePortalApp(network) } 190 191 // Expect the portal content to be fetched at some point after detecting the portal. 192 // Some implementations may fetch the URL before startCaptivePortalApp is called. 193 assertNotNull(server.requestsRecord.poll(TEST_TIMEOUT_MS, pos = 0) { 194 it.path == TEST_PORTAL_URL_PATH 195 }, "The captive portal login page was still not fetched ${TEST_TIMEOUT_MS}ms " + 196 "after startCaptivePortalApp.") 197 198 assertNotEquals(network, cm.activeNetwork, wifiDefaultMessage) 199 } finally { 200 cm.unregisterNetworkCallback(wifiCb) 201 server.stop() 202 // disconnectFromCell should be called after connectToCell 203 utils.disconnectFromCell() 204 } 205 } 206 207 /** 208 * Create a URL string that, when fetched, will hit the test server with the given URL [path]. 209 */ 210 private fun makeUrl(path: String) = "http://localhost:${server.listeningPort}" + path 211 212 private fun reconnectWifi() { 213 utils.ensureWifiDisconnected(null /* wifiNetworkToCheck */) 214 utils.ensureWifiConnected() 215 } 216 }