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
assertGetnull82 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 {
<lambda>null93 private val context: android.content.Context by lazy { getInstrumentation().context }
<lambda>null94 private val wm by lazy { context.getSystemService(WifiManager::class.java) }
<lambda>null95 private val cm by lazy { context.getSystemService(ConnectivityManager::class.java) }
<lambda>null96 private val pm by lazy { context.packageManager }
<lambda>null97 private val utils by lazy { CtsNetUtils(context) }
98
99 private val server = TestHttpServer("localhost")
100
101 @Before
setUpnull102 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
tearDownnull115 fun tearDown() {
116 clearValidationTestUrlsDeviceConfig()
117 if (pm.hasSystemFeature(FEATURE_WIFI)) {
118 reconnectWifi()
119 }
120 server.stop()
121 }
122
assertEmptyOrLocalhostUrlnull123 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
testCaptivePortalIsNotDefaultNetworknull130 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 */
makeUrlnull210 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 }