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.setHttpUrlDeviceConfig
37 import android.net.cts.NetworkValidationTestUtil.setHttpsUrlDeviceConfig
38 import android.net.cts.NetworkValidationTestUtil.setUrlExpirationDeviceConfig
39 import android.net.cts.util.CtsNetUtils
40 import android.platform.test.annotations.AppModeFull
41 import android.provider.DeviceConfig
42 import android.provider.DeviceConfig.NAMESPACE_CONNECTIVITY
43 import android.text.TextUtils
44 import android.util.Log
45 import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
46 import androidx.test.runner.AndroidJUnit4
47 import com.android.modules.utils.build.SdkLevel.isAtLeastR
48 import com.android.net.module.util.NetworkStackConstants.TEST_CAPTIVE_PORTAL_HTTPS_URL
49 import com.android.net.module.util.NetworkStackConstants.TEST_CAPTIVE_PORTAL_HTTP_URL
50 import com.android.testutils.DeviceConfigRule
51 import com.android.testutils.RecorderCallback.CallbackEntry.CapabilitiesChanged
52 import com.android.testutils.TestHttpServer
53 import com.android.testutils.TestHttpServer.Request
54 import com.android.testutils.TestableNetworkCallback
55 import com.android.testutils.runAsShell
56 import fi.iki.elonen.NanoHTTPD.Response.Status
57 import java.util.concurrent.CompletableFuture
58 import java.util.concurrent.TimeUnit
59 import java.util.concurrent.TimeoutException
60 import junit.framework.AssertionFailedError
61 import kotlin.test.Test
62 import kotlin.test.assertNotEquals
63 import kotlin.test.assertNotNull
64 import kotlin.test.assertTrue
65 import org.junit.After
66 import org.junit.Assume.assumeFalse
67 import org.junit.Assume.assumeTrue
68 import org.junit.Before
69 import org.junit.BeforeClass
70 import org.junit.Rule
71 import org.junit.runner.RunWith
72
73 private const val TEST_HTTPS_URL_PATH = "/https_path"
74 private const val TEST_HTTP_URL_PATH = "/http_path"
75 private const val TEST_PORTAL_URL_PATH = "/portal_path"
76
77 private const val LOCALHOST_HOSTNAME = "localhost"
78
79 // Re-connecting to the AP, obtaining an IP address, revalidating can take a long time
80 private const val WIFI_CONNECT_TIMEOUT_MS = 40_000L
81 private const val TEST_TIMEOUT_MS = 20_000L
82
83 private const val TAG = "CaptivePortalTest"
84
assertGetnull85 private fun <T> CompletableFuture<T>.assertGet(timeoutMs: Long, message: String): T {
86 try {
87 return get(timeoutMs, TimeUnit.MILLISECONDS)
88 } catch (e: TimeoutException) {
89 throw AssertionFailedError(message)
90 }
91 }
92
93 @AppModeFull(reason = "WRITE_DEVICE_CONFIG permission can't be granted to instant apps")
94 @RunWith(AndroidJUnit4::class)
95 class CaptivePortalTest {
<lambda>null96 private val context: android.content.Context by lazy { getInstrumentation().context }
<lambda>null97 private val cm by lazy { context.getSystemService(ConnectivityManager::class.java) }
<lambda>null98 private val pm by lazy { context.packageManager }
<lambda>null99 private val utils by lazy { CtsNetUtils(context) }
100
101 private val server = TestHttpServer("localhost")
102
103 @get:Rule
104 val deviceConfigRule = DeviceConfigRule(retryCountBeforeSIfConfigChanged = 5)
105
106 companion object {
107 @JvmStatic @BeforeClass
setUpClassnull108 fun setUpClass() {
109 runAsShell(READ_DEVICE_CONFIG) {
110 // Verify that the test URLs are not normally set on the device, but do not fail if
111 // the test URLs are set to what this test uses (URLs on localhost), in case the
112 // test was interrupted manually and rerun.
113 assertEmptyOrLocalhostUrl(TEST_CAPTIVE_PORTAL_HTTPS_URL)
114 assertEmptyOrLocalhostUrl(TEST_CAPTIVE_PORTAL_HTTP_URL)
115 }
116 NetworkValidationTestUtil.clearValidationTestUrlsDeviceConfig()
117 }
118
assertEmptyOrLocalhostUrlnull119 private fun assertEmptyOrLocalhostUrl(urlKey: String) {
120 val url = DeviceConfig.getProperty(NAMESPACE_CONNECTIVITY, urlKey)
121 assertTrue(TextUtils.isEmpty(url) || LOCALHOST_HOSTNAME == Uri.parse(url).host,
122 "$urlKey must not be set in production scenarios (current value: $url)")
123 }
124 }
125
126 @Before
setUpnull127 fun setUp() {
128 server.start()
129 }
130
131 @After
tearDownnull132 fun tearDown() {
133 if (pm.hasSystemFeature(FEATURE_WIFI)) {
134 deviceConfigRule.runAfterNextCleanup { reconnectWifi() }
135 }
136 server.stop()
137 }
138
139 @Test
testCaptivePortalIsNotDefaultNetworknull140 fun testCaptivePortalIsNotDefaultNetwork() {
141 assumeTrue(pm.hasSystemFeature(FEATURE_TELEPHONY))
142 assumeTrue(pm.hasSystemFeature(FEATURE_WIFI))
143 assumeFalse(pm.hasSystemFeature(FEATURE_WATCH))
144 utils.ensureWifiConnected()
145 val cellNetwork = utils.connectToCell()
146
147 // Verify cell network is validated
148 val cellReq = NetworkRequest.Builder()
149 .addTransportType(TRANSPORT_CELLULAR)
150 .addCapability(NET_CAPABILITY_INTERNET)
151 .build()
152 val cellCb = TestableNetworkCallback(timeoutMs = TEST_TIMEOUT_MS)
153 cm.registerNetworkCallback(cellReq, cellCb)
154 val cb = cellCb.poll { it.network == cellNetwork &&
155 it is CapabilitiesChanged && it.caps.hasCapability(NET_CAPABILITY_VALIDATED)
156 }
157 assertNotNull(cb, "Mobile network $cellNetwork has no access to the internet. " +
158 "Check the mobile data connection.")
159
160 // Have network validation use a local server that serves a HTTPS error / HTTP redirect
161 server.addResponse(Request(TEST_PORTAL_URL_PATH), Status.OK,
162 content = "Test captive portal content")
163 server.addResponse(Request(TEST_HTTPS_URL_PATH), Status.INTERNAL_ERROR)
164 val headers = mapOf("Location" to makeUrl(TEST_PORTAL_URL_PATH))
165 server.addResponse(Request(TEST_HTTP_URL_PATH), Status.REDIRECT, headers)
166 setHttpsUrlDeviceConfig(deviceConfigRule, makeUrl(TEST_HTTPS_URL_PATH))
167 setHttpUrlDeviceConfig(deviceConfigRule, makeUrl(TEST_HTTP_URL_PATH))
168 Log.d(TAG, "Set portal URLs to $TEST_HTTPS_URL_PATH and $TEST_HTTP_URL_PATH")
169 // URL expiration needs to be in the next 10 minutes
170 assertTrue(WIFI_CONNECT_TIMEOUT_MS < TimeUnit.MINUTES.toMillis(10))
171 setUrlExpirationDeviceConfig(deviceConfigRule,
172 System.currentTimeMillis() + WIFI_CONNECT_TIMEOUT_MS)
173
174 // Wait for a captive portal to be detected on the network
175 val wifiNetworkFuture = CompletableFuture<Network>()
176 val wifiCb = object : NetworkCallback() {
177 override fun onCapabilitiesChanged(
178 network: Network,
179 nc: NetworkCapabilities
180 ) {
181 if (nc.hasCapability(NET_CAPABILITY_CAPTIVE_PORTAL)) {
182 wifiNetworkFuture.complete(network)
183 }
184 }
185 }
186 cm.requestNetwork(NetworkRequest.Builder().addTransportType(TRANSPORT_WIFI).build(), wifiCb)
187
188 try {
189 reconnectWifi()
190 val network = wifiNetworkFuture.assertGet(WIFI_CONNECT_TIMEOUT_MS,
191 "Captive portal not detected after ${WIFI_CONNECT_TIMEOUT_MS}ms")
192
193 val wifiDefaultMessage = "Wifi should not be the default network when a captive " +
194 "portal was detected and another network (mobile data) can provide internet " +
195 "access."
196 assertNotEquals(network, cm.activeNetwork, wifiDefaultMessage)
197
198 val startPortalAppPermission =
199 if (isAtLeastR()) NETWORK_SETTINGS
200 else CONNECTIVITY_INTERNAL
201 runAsShell(startPortalAppPermission) { cm.startCaptivePortalApp(network) }
202
203 // Expect the portal content to be fetched at some point after detecting the portal.
204 // Some implementations may fetch the URL before startCaptivePortalApp is called.
205 assertNotNull(server.requestsRecord.poll(TEST_TIMEOUT_MS, pos = 0) {
206 it.path == TEST_PORTAL_URL_PATH
207 }, "The captive portal login page was still not fetched ${TEST_TIMEOUT_MS}ms " +
208 "after startCaptivePortalApp.")
209
210 assertNotEquals(network, cm.activeNetwork, wifiDefaultMessage)
211 } finally {
212 cm.unregisterNetworkCallback(wifiCb)
213 server.stop()
214 // disconnectFromCell should be called after connectToCell
215 utils.disconnectFromCell()
216 }
217 }
218
219 /**
220 * Create a URL string that, when fetched, will hit the test server with the given URL [path].
221 */
makeUrlnull222 private fun makeUrl(path: String) = "http://localhost:${server.listeningPort}" + path
223
224 private fun reconnectWifi() {
225 utils.ensureWifiDisconnected(null /* wifiNetworkToCheck */)
226 utils.ensureWifiConnected()
227 }
228 }
229