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.os.Build
44 import android.platform.test.annotations.AppModeFull
45 import android.provider.DeviceConfig
46 import android.provider.DeviceConfig.NAMESPACE_CONNECTIVITY
47 import android.text.TextUtils
48 import android.util.Log
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 = 40_000L
80 private const val TEST_TIMEOUT_MS = 20_000L
81
82 private const val TAG = "CaptivePortalTest"
83
assertGetnull84 private fun <T> CompletableFuture<T>.assertGet(timeoutMs: Long, message: String): T {
85 try {
86 return get(timeoutMs, TimeUnit.MILLISECONDS)
87 } catch (e: TimeoutException) {
88 throw AssertionFailedError(message)
89 }
90 }
91
92 @AppModeFull(reason = "WRITE_DEVICE_CONFIG permission can't be granted to instant apps")
93 @RunWith(AndroidJUnit4::class)
94 class CaptivePortalTest {
<lambda>null95 private val context: android.content.Context by lazy { getInstrumentation().context }
<lambda>null96 private val cm by lazy { context.getSystemService(ConnectivityManager::class.java) }
<lambda>null97 private val pm by lazy { context.packageManager }
<lambda>null98 private val utils by lazy { CtsNetUtils(context) }
99
100 private val server = TestHttpServer("localhost")
101
102 @Before
setUpnull103 fun setUp() {
104 runAsShell(READ_DEVICE_CONFIG) {
105 // Verify that the test URLs are not normally set on the device, but do not fail if the
106 // test URLs are set to what this test uses (URLs on localhost), in case the test was
107 // interrupted manually and rerun.
108 assertEmptyOrLocalhostUrl(TEST_CAPTIVE_PORTAL_HTTPS_URL)
109 assertEmptyOrLocalhostUrl(TEST_CAPTIVE_PORTAL_HTTP_URL)
110 }
111 clearValidationTestUrlsDeviceConfig()
112 server.start()
113 }
114
115 @After
tearDownnull116 fun tearDown() {
117 clearValidationTestUrlsDeviceConfig()
118 if (pm.hasSystemFeature(FEATURE_WIFI)) {
119 reconnectWifi()
120 }
121 server.stop()
122 }
123
assertEmptyOrLocalhostUrlnull124 private fun assertEmptyOrLocalhostUrl(urlKey: String) {
125 val url = DeviceConfig.getProperty(NAMESPACE_CONNECTIVITY, urlKey)
126 assertTrue(TextUtils.isEmpty(url) || LOCALHOST_HOSTNAME == Uri.parse(url).host,
127 "$urlKey must not be set in production scenarios (current value: $url)")
128 }
129
130 @Test
testCaptivePortalIsNotDefaultNetworknull131 fun testCaptivePortalIsNotDefaultNetwork() {
132 assumeTrue(pm.hasSystemFeature(FEATURE_TELEPHONY))
133 assumeTrue(pm.hasSystemFeature(FEATURE_WIFI))
134 assumeFalse(pm.hasSystemFeature(FEATURE_WATCH))
135 utils.ensureWifiConnected()
136 val cellNetwork = utils.connectToCell()
137
138 // Verify cell network is validated
139 val cellReq = NetworkRequest.Builder()
140 .addTransportType(TRANSPORT_CELLULAR)
141 .addCapability(NET_CAPABILITY_INTERNET)
142 .build()
143 val cellCb = TestableNetworkCallback(timeoutMs = TEST_TIMEOUT_MS)
144 cm.registerNetworkCallback(cellReq, cellCb)
145 val cb = cellCb.eventuallyExpectOrNull<RecorderCallback.CallbackEntry.CapabilitiesChanged> {
146 it.network == cellNetwork && it.caps.hasCapability(NET_CAPABILITY_VALIDATED)
147 }
148 assertNotNull(cb, "Mobile network $cellNetwork has no access to the internet. " +
149 "Check the mobile data connection.")
150
151 // Have network validation use a local server that serves a HTTPS error / HTTP redirect
152 server.addResponse(Request(TEST_PORTAL_URL_PATH), Status.OK,
153 content = "Test captive portal content")
154 server.addResponse(Request(TEST_HTTPS_URL_PATH), Status.INTERNAL_ERROR)
155 val headers = mapOf("Location" to makeUrl(TEST_PORTAL_URL_PATH))
156 server.addResponse(Request(TEST_HTTP_URL_PATH), Status.REDIRECT, headers)
157 setHttpsUrlDeviceConfig(makeUrl(TEST_HTTPS_URL_PATH))
158 setHttpUrlDeviceConfig(makeUrl(TEST_HTTP_URL_PATH))
159 Log.d(TAG, "Set portal URLs to $TEST_HTTPS_URL_PATH and $TEST_HTTP_URL_PATH")
160 // URL expiration needs to be in the next 10 minutes
161 assertTrue(WIFI_CONNECT_TIMEOUT_MS < TimeUnit.MINUTES.toMillis(10))
162 setUrlExpirationDeviceConfig(System.currentTimeMillis() + WIFI_CONNECT_TIMEOUT_MS)
163
164 // Wait for a captive portal to be detected on the network
165 val wifiNetworkFuture = CompletableFuture<Network>()
166 val wifiCb = object : NetworkCallback() {
167 override fun onCapabilitiesChanged(
168 network: Network,
169 nc: NetworkCapabilities
170 ) {
171 if (nc.hasCapability(NET_CAPABILITY_CAPTIVE_PORTAL)) {
172 wifiNetworkFuture.complete(network)
173 }
174 }
175 }
176 cm.requestNetwork(NetworkRequest.Builder().addTransportType(TRANSPORT_WIFI).build(), wifiCb)
177
178 try {
179 reconnectWifi()
180 val network = wifiNetworkFuture.assertGet(WIFI_CONNECT_TIMEOUT_MS,
181 "Captive portal not detected after ${WIFI_CONNECT_TIMEOUT_MS}ms")
182
183 val wifiDefaultMessage = "Wifi should not be the default network when a captive " +
184 "portal was detected and another network (mobile data) can provide internet " +
185 "access."
186 assertNotEquals(network, cm.activeNetwork, wifiDefaultMessage)
187
188 val startPortalAppPermission =
189 if (isDevSdkInRange(0, Build.VERSION_CODES.Q)) CONNECTIVITY_INTERNAL
190 else NETWORK_SETTINGS
191 runAsShell(startPortalAppPermission) { cm.startCaptivePortalApp(network) }
192
193 // Expect the portal content to be fetched at some point after detecting the portal.
194 // Some implementations may fetch the URL before startCaptivePortalApp is called.
195 assertNotNull(server.requestsRecord.poll(TEST_TIMEOUT_MS, pos = 0) {
196 it.path == TEST_PORTAL_URL_PATH
197 }, "The captive portal login page was still not fetched ${TEST_TIMEOUT_MS}ms " +
198 "after startCaptivePortalApp.")
199
200 assertNotEquals(network, cm.activeNetwork, wifiDefaultMessage)
201 } finally {
202 cm.unregisterNetworkCallback(wifiCb)
203 server.stop()
204 // disconnectFromCell should be called after connectToCell
205 utils.disconnectFromCell()
206 }
207 }
208
209 /**
210 * Create a URL string that, when fetched, will hit the test server with the given URL [path].
211 */
makeUrlnull212 private fun makeUrl(path: String) = "http://localhost:${server.listeningPort}" + path
213
214 private fun reconnectWifi() {
215 utils.ensureWifiDisconnected(null /* wifiNetworkToCheck */)
216 utils.ensureWifiConnected()
217 }
218 }