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.Manifest.permission.WRITE_DEVICE_CONFIG
23 import android.content.pm.PackageManager.FEATURE_TELEPHONY
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.TRANSPORT_WIFI
31 import android.net.NetworkRequest
32 import android.net.Uri
33 import android.net.cts.util.CtsNetUtils
34 import android.net.wifi.WifiManager
35 import android.os.Build
36 import android.os.ConditionVariable
37 import android.platform.test.annotations.AppModeFull
38 import android.provider.DeviceConfig
39 import android.provider.DeviceConfig.NAMESPACE_CONNECTIVITY
40 import android.text.TextUtils
41 import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
42 import androidx.test.runner.AndroidJUnit4
43 import com.android.compatibility.common.util.SystemUtil
44 import com.android.testutils.isDevSdkInRange
45 import fi.iki.elonen.NanoHTTPD
46 import fi.iki.elonen.NanoHTTPD.Response.IStatus
47 import fi.iki.elonen.NanoHTTPD.Response.Status
48 import junit.framework.AssertionFailedError
49 import org.junit.After
50 import org.junit.Assume.assumeTrue
51 import org.junit.Before
52 import org.junit.runner.RunWith
53 import java.util.concurrent.CompletableFuture
54 import java.util.concurrent.TimeUnit
55 import java.util.concurrent.TimeoutException
56 import kotlin.test.Test
57 import kotlin.test.assertNotEquals
58 import kotlin.test.assertTrue
59
60 private const val TEST_CAPTIVE_PORTAL_HTTPS_URL_SETTING = "test_captive_portal_https_url"
61 private const val TEST_CAPTIVE_PORTAL_HTTP_URL_SETTING = "test_captive_portal_http_url"
62 private const val TEST_URL_EXPIRATION_TIME = "test_url_expiration_time"
63
64 private const val TEST_HTTPS_URL_PATH = "https_path"
65 private const val TEST_HTTP_URL_PATH = "http_path"
66 private const val TEST_PORTAL_URL_PATH = "portal_path"
67
68 private const val LOCALHOST_HOSTNAME = "localhost"
69
70 // Re-connecting to the AP, obtaining an IP address, revalidating can take a long time
71 private const val WIFI_CONNECT_TIMEOUT_MS = 120_000L
72 private const val TEST_TIMEOUT_MS = 10_000L
73
assertGetnull74 private fun <T> CompletableFuture<T>.assertGet(timeoutMs: Long, message: String): T {
75 try {
76 return get(timeoutMs, TimeUnit.MILLISECONDS)
77 } catch (e: TimeoutException) {
78 throw AssertionFailedError(message)
79 }
80 }
81
82 @AppModeFull(reason = "WRITE_DEVICE_CONFIG permission can't be granted to instant apps")
83 @RunWith(AndroidJUnit4::class)
84 class CaptivePortalTest {
<lambda>null85 private val context: android.content.Context by lazy { getInstrumentation().context }
<lambda>null86 private val wm by lazy { context.getSystemService(WifiManager::class.java) }
<lambda>null87 private val cm by lazy { context.getSystemService(ConnectivityManager::class.java) }
<lambda>null88 private val pm by lazy { context.packageManager }
<lambda>null89 private val utils by lazy { CtsNetUtils(context) }
90
91 private val server = HttpServer()
92
93 @Before
setUpnull94 fun setUp() {
95 doAsShell(READ_DEVICE_CONFIG) {
96 // Verify that the test URLs are not normally set on the device, but do not fail if the
97 // test URLs are set to what this test uses (URLs on localhost), in case the test was
98 // interrupted manually and rerun.
99 assertEmptyOrLocalhostUrl(TEST_CAPTIVE_PORTAL_HTTPS_URL_SETTING)
100 assertEmptyOrLocalhostUrl(TEST_CAPTIVE_PORTAL_HTTP_URL_SETTING)
101 }
102 clearTestUrls()
103 server.start()
104 }
105
106 @After
tearDownnull107 fun tearDown() {
108 clearTestUrls()
109 if (pm.hasSystemFeature(FEATURE_WIFI)) {
110 reconnectWifi()
111 }
112 server.stop()
113 }
114
assertEmptyOrLocalhostUrlnull115 private fun assertEmptyOrLocalhostUrl(urlKey: String) {
116 val url = DeviceConfig.getProperty(NAMESPACE_CONNECTIVITY, urlKey)
117 assertTrue(TextUtils.isEmpty(url) || LOCALHOST_HOSTNAME == Uri.parse(url).host,
118 "$urlKey must not be set in production scenarios (current value: $url)")
119 }
120
clearTestUrlsnull121 private fun clearTestUrls() {
122 setHttpsUrl(null)
123 setHttpUrl(null)
124 setUrlExpiration(null)
125 }
126
127 @Test
testCaptivePortalIsNotDefaultNetworknull128 fun testCaptivePortalIsNotDefaultNetwork() {
129 assumeTrue(pm.hasSystemFeature(FEATURE_TELEPHONY))
130 assumeTrue(pm.hasSystemFeature(FEATURE_WIFI))
131 utils.connectToWifi()
132 utils.connectToCell()
133
134 // Have network validation use a local server that serves a HTTPS error / HTTP redirect
135 server.addResponse(TEST_PORTAL_URL_PATH, Status.OK,
136 content = "Test captive portal content")
137 server.addResponse(TEST_HTTPS_URL_PATH, Status.INTERNAL_ERROR)
138 server.addResponse(TEST_HTTP_URL_PATH, Status.REDIRECT,
139 locationHeader = server.makeUrl(TEST_PORTAL_URL_PATH))
140 setHttpsUrl(server.makeUrl(TEST_HTTPS_URL_PATH))
141 setHttpUrl(server.makeUrl(TEST_HTTP_URL_PATH))
142 // URL expiration needs to be in the next 10 minutes
143 setUrlExpiration(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(9))
144
145 // Expect the portal content to be fetched at some point after detecting the portal.
146 // Some implementations may fetch the URL before startCaptivePortalApp is called.
147 val portalContentRequestCv = server.addExpectRequestCv(TEST_PORTAL_URL_PATH)
148
149 // Wait for a captive portal to be detected on the network
150 val wifiNetworkFuture = CompletableFuture<Network>()
151 val wifiCb = object : NetworkCallback() {
152 override fun onCapabilitiesChanged(
153 network: Network,
154 nc: NetworkCapabilities
155 ) {
156 if (nc.hasCapability(NET_CAPABILITY_CAPTIVE_PORTAL)) {
157 wifiNetworkFuture.complete(network)
158 }
159 }
160 }
161 cm.requestNetwork(NetworkRequest.Builder().addTransportType(TRANSPORT_WIFI).build(), wifiCb)
162
163 try {
164 reconnectWifi()
165 val network = wifiNetworkFuture.assertGet(WIFI_CONNECT_TIMEOUT_MS,
166 "Captive portal not detected after ${WIFI_CONNECT_TIMEOUT_MS}ms")
167
168 val wifiDefaultMessage = "Wifi should not be the default network when a captive " +
169 "portal was detected and another network (mobile data) can provide internet " +
170 "access."
171 assertNotEquals(network, cm.activeNetwork, wifiDefaultMessage)
172
173 val startPortalAppPermission =
174 if (isDevSdkInRange(0, Build.VERSION_CODES.Q)) CONNECTIVITY_INTERNAL
175 else NETWORK_SETTINGS
176 doAsShell(startPortalAppPermission) { cm.startCaptivePortalApp(network) }
177 assertTrue(portalContentRequestCv.block(TEST_TIMEOUT_MS), "The captive portal login " +
178 "page was still not fetched ${TEST_TIMEOUT_MS}ms after startCaptivePortalApp.")
179
180 assertNotEquals(network, cm.activeNetwork, wifiDefaultMessage)
181 } finally {
182 cm.unregisterNetworkCallback(wifiCb)
183 server.stop()
184 // disconnectFromCell should be called after connectToCell
185 utils.disconnectFromCell()
186 }
187 }
188
setHttpsUrlnull189 private fun setHttpsUrl(url: String?) = setConfig(TEST_CAPTIVE_PORTAL_HTTPS_URL_SETTING, url)
190 private fun setHttpUrl(url: String?) = setConfig(TEST_CAPTIVE_PORTAL_HTTP_URL_SETTING, url)
191 private fun setUrlExpiration(timestamp: Long?) = setConfig(TEST_URL_EXPIRATION_TIME,
192 timestamp?.toString())
193
194 private fun setConfig(configKey: String, value: String?) {
195 doAsShell(WRITE_DEVICE_CONFIG) {
196 DeviceConfig.setProperty(
197 NAMESPACE_CONNECTIVITY, configKey, value, false /* makeDefault */)
198 }
199 }
200
doAsShellnull201 private fun doAsShell(vararg permissions: String, action: () -> Unit) {
202 // Wrap the below call to allow for more kotlin-like syntax
203 SystemUtil.runWithShellPermissionIdentity(action, permissions)
204 }
205
reconnectWifinull206 private fun reconnectWifi() {
207 utils.ensureWifiDisconnected(null /* wifiNetworkToCheck */)
208 utils.ensureWifiConnected()
209 }
210
211 /**
212 * A minimal HTTP server running on localhost (loopback), on a random available port.
213 */
214 private class HttpServer : NanoHTTPD("localhost", 0 /* auto-select the port */) {
215 // Map of URL path -> HTTP response code
216 private val responses = HashMap<String, Response>()
217
218 // Map of path -> CV to open as soon as a request to the path is received
219 private val waitForRequestCv = HashMap<String, ConditionVariable>()
220
221 /**
222 * Create a URL string that, when fetched, will hit this server with the given URL [path].
223 */
makeUrlnull224 fun makeUrl(path: String): String {
225 return Uri.Builder()
226 .scheme("http")
227 .encodedAuthority("localhost:$listeningPort")
228 .query(path)
229 .build()
230 .toString()
231 }
232
addResponsenull233 fun addResponse(
234 path: String,
235 statusCode: IStatus,
236 locationHeader: String? = null,
237 content: String = ""
238 ) {
239 val response = newFixedLengthResponse(statusCode, "text/plain", content)
240 locationHeader?.let { response.addHeader("Location", it) }
241 responses[path] = response
242 }
243
244 /**
245 * Create a [ConditionVariable] that will open when a request to [path] is received.
246 */
addExpectRequestCvnull247 fun addExpectRequestCv(path: String): ConditionVariable {
248 return ConditionVariable().apply { waitForRequestCv[path] = this }
249 }
250
servenull251 override fun serve(session: IHTTPSession): Response {
252 waitForRequestCv[session.queryParameterString]?.open()
253 return responses[session.queryParameterString]
254 // Default response is a 404
255 ?: super.serve(session)
256 }
257 }
258 }