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 }