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 com.android.captiveportallogin; 18 19 import static android.Manifest.permission.MANAGE_TEST_NETWORKS; 20 import static android.app.Activity.RESULT_OK; 21 import static android.content.Intent.ACTION_CREATE_DOCUMENT; 22 import static android.net.ConnectivityManager.ACTION_CAPTIVE_PORTAL_SIGN_IN; 23 import static android.net.ConnectivityManager.EXTRA_CAPTIVE_PORTAL; 24 import static android.net.ConnectivityManager.EXTRA_CAPTIVE_PORTAL_URL; 25 import static android.net.ConnectivityManager.EXTRA_CAPTIVE_PORTAL_USER_AGENT; 26 import static android.net.ConnectivityManager.EXTRA_NETWORK; 27 import static android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED; 28 import static android.provider.DeviceConfig.NAMESPACE_CONNECTIVITY; 29 30 import static androidx.test.espresso.intent.Intents.intending; 31 import static androidx.test.espresso.intent.matcher.IntentMatchers.hasAction; 32 import static androidx.test.espresso.intent.matcher.IntentMatchers.isInternal; 33 import static androidx.test.espresso.web.sugar.Web.onWebView; 34 import static androidx.test.espresso.web.webdriver.DriverAtoms.findElement; 35 import static androidx.test.espresso.web.webdriver.DriverAtoms.webClick; 36 import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; 37 38 import static com.android.dx.mockito.inline.extended.ExtendedMockito.doReturn; 39 import static com.android.dx.mockito.inline.extended.ExtendedMockito.mockitoSession; 40 import static com.android.testutils.TestNetworkTrackerKt.initTestNetwork; 41 42 import static junit.framework.Assert.assertEquals; 43 44 import static org.hamcrest.CoreMatchers.not; 45 import static org.junit.Assert.assertFalse; 46 import static org.junit.Assert.assertNotNull; 47 import static org.junit.Assert.assertTrue; 48 import static org.junit.Assume.assumeTrue; 49 import static org.mockito.Mockito.any; 50 import static org.mockito.Mockito.mock; 51 import static org.mockito.Mockito.spy; 52 import static org.mockito.Mockito.when; 53 54 import android.app.Instrumentation.ActivityResult; 55 import android.app.KeyguardManager; 56 import android.app.UiAutomation; 57 import android.app.admin.DevicePolicyManager; 58 import android.content.ComponentName; 59 import android.content.Context; 60 import android.content.Intent; 61 import android.net.CaptivePortal; 62 import android.net.CaptivePortalData; 63 import android.net.ConnectivityManager; 64 import android.net.InetAddresses; 65 import android.net.LinkAddress; 66 import android.net.LinkProperties; 67 import android.net.Network; 68 import android.net.NetworkCapabilities; 69 import android.net.Uri; 70 import android.net.wifi.WifiInfo; 71 import android.net.wifi.WifiManager; 72 import android.os.Build; 73 import android.os.Bundle; 74 import android.os.ConditionVariable; 75 import android.os.Parcel; 76 import android.os.Parcelable; 77 import android.provider.DeviceConfig; 78 79 import androidx.test.core.app.ActivityScenario; 80 import androidx.test.espresso.intent.Intents; 81 import androidx.test.espresso.intent.rule.IntentsTestRule; 82 import androidx.test.espresso.web.webdriver.Locator; 83 import androidx.test.ext.junit.runners.AndroidJUnit4; 84 import androidx.test.filters.SdkSuppress; 85 import androidx.test.filters.SmallTest; 86 87 import com.android.testutils.TestNetworkTracker; 88 89 import org.junit.After; 90 import org.junit.Before; 91 import org.junit.Rule; 92 import org.junit.Test; 93 import org.junit.runner.RunWith; 94 import org.mockito.MockitoAnnotations; 95 import org.mockito.MockitoSession; 96 import org.mockito.quality.Strictness; 97 98 import java.io.IOException; 99 import java.lang.reflect.Method; 100 import java.net.ServerSocket; 101 import java.nio.charset.StandardCharsets; 102 import java.util.Collections; 103 import java.util.HashMap; 104 import java.util.Map; 105 import java.util.concurrent.CompletableFuture; 106 import java.util.concurrent.TimeUnit; 107 import java.util.function.BooleanSupplier; 108 109 import fi.iki.elonen.NanoHTTPD; 110 111 @RunWith(AndroidJUnit4.class) 112 @SmallTest 113 public class CaptivePortalLoginActivityTest { 114 private static final String TEST_URL = "http://android.test.com"; 115 private static final int TEST_NETID = 1234; 116 private static final String TEST_NC_SSID = "Test NetworkCapabilities SSID"; 117 private static final String TEST_WIFIINFO_SSID = "Test Other SSID"; 118 private static final String TEST_URL_QUERY = "testquery"; 119 private static final long TEST_TIMEOUT_MS = 10_000L; 120 private static final LinkAddress TEST_LINKADDR = new LinkAddress( 121 InetAddresses.parseNumericAddress("2001:db8::8"), 64); 122 private static final String TEST_USERAGENT = "Test/42.0 Unit-test"; 123 private static final String TEST_FRIENDLY_NAME = "Network friendly name"; 124 private InstrumentedCaptivePortalLoginActivity mActivity; 125 private MockitoSession mSession; 126 private Network mNetwork = new Network(TEST_NETID); 127 private TestNetworkTracker mTestNetworkTracker; 128 129 private static ConnectivityManager sConnectivityManager; 130 private static WifiManager sMockWifiManager; 131 private static DevicePolicyManager sMockDevicePolicyManager; 132 133 public static class InstrumentedCaptivePortalLoginActivity extends CaptivePortalLoginActivity { 134 private final ConditionVariable mDestroyedCv = new ConditionVariable(false); 135 private final CompletableFuture<Intent> mForegroundServiceStart = new CompletableFuture<>(); 136 @Override getSystemService(String name)137 public Object getSystemService(String name) { 138 switch (name) { 139 case Context.CONNECTIVITY_SERVICE: 140 return sConnectivityManager; 141 case Context.DEVICE_POLICY_SERVICE: 142 return sMockDevicePolicyManager; 143 case Context.WIFI_SERVICE: 144 return sMockWifiManager; 145 default: 146 return super.getSystemService(name); 147 } 148 } 149 150 @Override startForegroundService(Intent service)151 public ComponentName startForegroundService(Intent service) { 152 assertTrue("Multiple foreground services were started during the test", 153 mForegroundServiceStart.complete(service)); 154 // Do not actually start the service 155 return service.getComponent(); 156 } 157 158 @Override onDestroy()159 public void onDestroy() { 160 super.onDestroy(); 161 mDestroyedCv.open(); 162 } 163 waitForDestroy(long timeoutMs)164 void waitForDestroy(long timeoutMs) { 165 assertTrue("Activity not destroyed within timeout", mDestroyedCv.block(timeoutMs)); 166 } 167 } 168 169 /** Class to replace CaptivePortal to prevent mock object is updated and replaced by parcel. */ 170 public static class MockCaptivePortal extends CaptivePortal { 171 int mDismissTimes; 172 int mIgnoreTimes; 173 int mUseTimes; 174 MockCaptivePortal()175 private MockCaptivePortal() { 176 this(0, 0, 0); 177 } MockCaptivePortal(int dismissTimes, int ignoreTimes, int useTimes)178 private MockCaptivePortal(int dismissTimes, int ignoreTimes, int useTimes) { 179 super(null); 180 mDismissTimes = dismissTimes; 181 mIgnoreTimes = ignoreTimes; 182 mUseTimes = useTimes; 183 } 184 @Override reportCaptivePortalDismissed()185 public void reportCaptivePortalDismissed() { 186 mDismissTimes++; 187 } 188 189 @Override ignoreNetwork()190 public void ignoreNetwork() { 191 mIgnoreTimes++; 192 } 193 194 @Override useNetwork()195 public void useNetwork() { 196 mUseTimes++; 197 } 198 199 @Override writeToParcel(Parcel out, int flags)200 public void writeToParcel(Parcel out, int flags) { 201 out.writeInt(mDismissTimes); 202 out.writeInt(mIgnoreTimes); 203 out.writeInt(mUseTimes); 204 } 205 206 public static final Parcelable.Creator<MockCaptivePortal> CREATOR = 207 new Parcelable.Creator<MockCaptivePortal>() { 208 @Override 209 public MockCaptivePortal createFromParcel(Parcel in) { 210 return new MockCaptivePortal(in.readInt(), in.readInt(), in.readInt()); 211 } 212 213 @Override 214 public MockCaptivePortal[] newArray(int size) { 215 return new MockCaptivePortal[size]; 216 } 217 }; 218 } 219 220 @Rule 221 public final IntentsTestRule mActivityRule = 222 new IntentsTestRule<>(InstrumentedCaptivePortalLoginActivity.class, 223 false /* initialTouchMode */, false /* launchActivity */); 224 225 @Before setUp()226 public void setUp() throws Exception { 227 final Context context = getInstrumentation().getContext(); 228 sConnectivityManager = spy(context.getSystemService(ConnectivityManager.class)); 229 sMockWifiManager = mock(WifiManager.class); 230 sMockDevicePolicyManager = mock(DevicePolicyManager.class); 231 MockitoAnnotations.initMocks(this); 232 mSession = mockitoSession() 233 .spyStatic(DeviceConfig.class) 234 .strictness(Strictness.WARN) 235 .startMocking(); 236 setDismissPortalInValidatedNetwork(true); 237 // Use a real (but test) network for the application. The application will pass this 238 // network to ConnectivityManager#bindProcessToNetwork, so it needs to be a real, existing 239 // network on the device but otherwise has no functional use at all. The http server set up 240 // by this test will run on the loopback interface and will not use this test network. 241 final UiAutomation automation = getInstrumentation().getUiAutomation(); 242 automation.adoptShellPermissionIdentity(MANAGE_TEST_NETWORKS); 243 try { 244 mTestNetworkTracker = initTestNetwork( 245 getInstrumentation().getContext(), TEST_LINKADDR, TEST_TIMEOUT_MS); 246 } finally { 247 automation.dropShellPermissionIdentity(); 248 } 249 mNetwork = mTestNetworkTracker.getNetwork(); 250 251 final WifiInfo testInfo = makeWifiInfo(); 252 doReturn(testInfo).when(sMockWifiManager).getConnectionInfo(); 253 } 254 makeWifiInfo()255 private static WifiInfo makeWifiInfo() throws Exception { 256 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { 257 return new WifiInfo.Builder() 258 .setSsid(TEST_WIFIINFO_SSID.getBytes(StandardCharsets.US_ASCII)) 259 .build(); 260 } 261 262 // WifiInfo did not have a builder before R. Use non-public APIs on Q to set SSID. 263 final WifiInfo info = WifiInfo.class.getConstructor().newInstance(); 264 final Class<?> wifiSsidClass = Class.forName("android.net.wifi.WifiSsid"); 265 final Object wifiSsid = wifiSsidClass.getMethod("createFromAsciiEncoded", 266 String.class).invoke(null, TEST_WIFIINFO_SSID); 267 WifiInfo.class.getMethod("setSSID", wifiSsidClass).invoke(info, wifiSsid); 268 return info; 269 } 270 271 @After tearDown()272 public void tearDown() throws Exception { 273 mActivityRule.finishActivity(); 274 if (mActivity != null) mActivity.waitForDestroy(TEST_TIMEOUT_MS); 275 getInstrumentation().getContext().getSystemService(ConnectivityManager.class) 276 .bindProcessToNetwork(null); 277 if (mTestNetworkTracker != null) mTestNetworkTracker.teardown(); 278 // finish mocking after the activity has terminated to avoid races on teardown. 279 mSession.finishMocking(); 280 } 281 initActivity(String url)282 private void initActivity(String url) { 283 // onCreate will be triggered in launchActivity(). Handle mock objects after 284 // launchActivity() if any new mock objects. Activity launching flow will be 285 // 1. launchActivity() 286 // 2. onCreate() 287 // 3. end of launchActivity() 288 mActivity = (InstrumentedCaptivePortalLoginActivity) mActivityRule.launchActivity( 289 new Intent(ACTION_CAPTIVE_PORTAL_SIGN_IN) 290 .putExtra(EXTRA_CAPTIVE_PORTAL_URL, url) 291 .putExtra(EXTRA_NETWORK, mNetwork) 292 .putExtra(EXTRA_CAPTIVE_PORTAL_USER_AGENT, TEST_USERAGENT) 293 .putExtra(EXTRA_CAPTIVE_PORTAL, new MockCaptivePortal()) 294 ); 295 // Verify activity created successfully. 296 assertNotNull(mActivity); 297 getInstrumentation().getContext().getSystemService(KeyguardManager.class) 298 .requestDismissKeyguard(mActivity, null); 299 // Dismiss dialogs or notification shade, so that the test can interact with the activity. 300 mActivity.sendBroadcast(new Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS)); 301 getInstrumentation().waitForIdleSync(); 302 } 303 304 @Test testonCreateWithNullCaptivePortal()305 public void testonCreateWithNullCaptivePortal() throws Exception { 306 mActivity = (InstrumentedCaptivePortalLoginActivity) mActivityRule.launchActivity( 307 new Intent(ACTION_CAPTIVE_PORTAL_SIGN_IN) 308 .putExtra(EXTRA_CAPTIVE_PORTAL_URL, TEST_URL) 309 .putExtra(EXTRA_NETWORK, mNetwork) 310 .putExtra(EXTRA_CAPTIVE_PORTAL_USER_AGENT, TEST_USERAGENT) 311 .putExtra(EXTRA_CAPTIVE_PORTAL, (Bundle) null)); 312 // Verify that activity is still created but waiting for closing. 313 assertNotNull(mActivity); 314 } 315 getCaptivePortal()316 private MockCaptivePortal getCaptivePortal() { 317 return (MockCaptivePortal) mActivity.mCaptivePortal; 318 } 319 configNonVpnNetwork()320 private void configNonVpnNetwork() { 321 final Network[] networks = new Network[] {new Network(mNetwork)}; 322 doReturn(networks).when(sConnectivityManager).getAllNetworks(); 323 final NetworkCapabilities nonVpnCapabilities; 324 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { 325 // SSID and NetworkCapabilities builder was added in R 326 nonVpnCapabilities = new NetworkCapabilities.Builder() 327 .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) 328 .setSsid(TEST_NC_SSID) 329 .build(); 330 } else { 331 nonVpnCapabilities = new NetworkCapabilities() 332 .addTransportType(NetworkCapabilities.TRANSPORT_WIFI); 333 } 334 doReturn(nonVpnCapabilities).when(sConnectivityManager).getNetworkCapabilities( 335 mNetwork); 336 } 337 configVpnNetwork()338 private void configVpnNetwork() { 339 final Network network1 = new Network(TEST_NETID + 1); 340 final Network network2 = new Network(TEST_NETID + 2); 341 final Network[] networks = new Network[] {network1, network2}; 342 doReturn(networks).when(sConnectivityManager).getAllNetworks(); 343 final NetworkCapabilities underlyingCapabilities = new NetworkCapabilities() 344 .addTransportType(NetworkCapabilities.TRANSPORT_WIFI); 345 final NetworkCapabilities vpnCapabilities = new NetworkCapabilities(underlyingCapabilities) 346 .addTransportType(NetworkCapabilities.TRANSPORT_VPN); 347 doReturn(underlyingCapabilities).when(sConnectivityManager).getNetworkCapabilities( 348 network1); 349 doReturn(vpnCapabilities).when(sConnectivityManager).getNetworkCapabilities(network2); 350 } 351 352 @Test testHasVpnNetwork()353 public void testHasVpnNetwork() throws Exception { 354 initActivity(TEST_URL); 355 // Test non-vpn case. 356 configNonVpnNetwork(); 357 assertFalse(mActivity.hasVpnNetwork()); 358 // Test vpn case. 359 configVpnNetwork(); 360 assertTrue(mActivity.hasVpnNetwork()); 361 } 362 363 @Test testIsAlwaysOnVpnEnabled()364 public void testIsAlwaysOnVpnEnabled() throws Exception { 365 initActivity(TEST_URL); 366 doReturn(false).when(sMockDevicePolicyManager).isAlwaysOnVpnLockdownEnabled(any()); 367 assertFalse(mActivity.isAlwaysOnVpnEnabled()); 368 doReturn(true).when(sMockDevicePolicyManager).isAlwaysOnVpnLockdownEnabled(any()); 369 assertTrue(mActivity.isAlwaysOnVpnEnabled()); 370 } 371 runVpnMsgOrLinkToBrowser(boolean useVpnMatcher)372 private void runVpnMsgOrLinkToBrowser(boolean useVpnMatcher) { 373 initActivity(TEST_URL); 374 // Test non-vpn case. 375 configNonVpnNetwork(); 376 doReturn(false).when(sMockDevicePolicyManager).isAlwaysOnVpnLockdownEnabled(any()); 377 final String linkMatcher = ".*<a[^>]+href.*"; 378 assertTrue(mActivity.getWebViewClient().getVpnMsgOrLinkToBrowser().matches(linkMatcher)); 379 380 // Test has vpn case. 381 configVpnNetwork(); 382 final String vpnMatcher = ".*<div.*vpnwarning.*"; 383 assertTrue(mActivity.getWebViewClient().getVpnMsgOrLinkToBrowser().matches(vpnMatcher)); 384 385 // Test always-on vpn case. 386 configNonVpnNetwork(); 387 doReturn(true).when(sMockDevicePolicyManager).isAlwaysOnVpnLockdownEnabled(any()); 388 assertTrue(mActivity.getWebViewClient().getVpnMsgOrLinkToBrowser().matches( 389 (useVpnMatcher ? vpnMatcher : linkMatcher))); 390 } 391 392 @Test @SdkSuppress(maxSdkVersion = Build.VERSION_CODES.Q) testVpnMsgOrLinkToBrowser_BeforeR()393 public void testVpnMsgOrLinkToBrowser_BeforeR() throws Exception { 394 // Before Android R, CaptivePortalLogin cannot call isAlwaysOnVpnLockdownEnabled() due to 395 // permission denied. So CaptivePortalLogin doesn't know the status of VPN always-on, and it 396 // simply provides a link for user to open the browser as usual. 397 runVpnMsgOrLinkToBrowser(false /* useVpnMatcher */); 398 } 399 400 @Test @SdkSuppress(minSdkVersion = Build.VERSION_CODES.R) testVpnMsgOrLinkToBrowser()401 public void testVpnMsgOrLinkToBrowser() throws Exception { 402 // After Android R(including), DevicePolicyManager allows the caller who has the 403 // PERMISSION_MAINLINE_NETWORK_STACK can call the isAlwaysOnVpnLockdownEnabled() to get the 404 // status of VPN always-on. So the CaptivePortalLogin could know the status of VPN always-on 405 // and show the related warning message to the user. 406 runVpnMsgOrLinkToBrowser(true /* useVpnMatcher */); 407 } 408 notifyCapabilitiesChanged(final NetworkCapabilities nc)409 private void notifyCapabilitiesChanged(final NetworkCapabilities nc) { 410 mActivity.handleCapabilitiesChanged(mNetwork, nc); 411 getInstrumentation().waitForIdleSync(); 412 } 413 verifyDismissed()414 private void verifyDismissed() { 415 final MockCaptivePortal cp = getCaptivePortal(); 416 assertEquals(cp.mDismissTimes, 1); 417 assertEquals(cp.mIgnoreTimes, 0); 418 assertEquals(cp.mUseTimes, 0); 419 } 420 notifyValidatedChangedAndDismissed(final NetworkCapabilities nc)421 private void notifyValidatedChangedAndDismissed(final NetworkCapabilities nc) { 422 notifyCapabilitiesChanged(nc); 423 verifyDismissed(); 424 } 425 verifyNotDone()426 private void verifyNotDone() { 427 final MockCaptivePortal cp = getCaptivePortal(); 428 assertEquals(cp.mDismissTimes, 0); 429 assertEquals(cp.mIgnoreTimes, 0); 430 assertEquals(cp.mUseTimes, 0); 431 } 432 notifyValidatedChangedNotDone(final NetworkCapabilities nc)433 private void notifyValidatedChangedNotDone(final NetworkCapabilities nc) { 434 notifyCapabilitiesChanged(nc); 435 verifyNotDone(); 436 } 437 verifyUseAsIs()438 private void verifyUseAsIs() { 439 final MockCaptivePortal cp = getCaptivePortal(); 440 assertEquals(cp.mDismissTimes, 0); 441 assertEquals(cp.mIgnoreTimes, 0); 442 assertEquals(cp.mUseTimes, 1); 443 } 444 setDismissPortalInValidatedNetwork(final boolean enable)445 private void setDismissPortalInValidatedNetwork(final boolean enable) { 446 // Feature is enabled if the package version greater than configuration. Instead of reading 447 // the package version, use Long.MAX_VALUE to replace disable configuration and 1 for 448 // enabling. 449 doReturn(enable ? 1 : Long.MAX_VALUE).when(() -> DeviceConfig.getLong( 450 NAMESPACE_CONNECTIVITY, 451 CaptivePortalLoginActivity.DISMISS_PORTAL_IN_VALIDATED_NETWORK, 0 /* default */)); 452 } 453 454 @Test testNetworkCapabilitiesUpdate()455 public void testNetworkCapabilitiesUpdate() throws Exception { 456 initActivity(TEST_URL); 457 // NetworkCapabilities updates w/o NET_CAPABILITY_VALIDATED. 458 final NetworkCapabilities nc = new NetworkCapabilities(); 459 notifyValidatedChangedNotDone(nc); 460 461 // NetworkCapabilities updates w/ NET_CAPABILITY_VALIDATED. 462 nc.setCapability(NET_CAPABILITY_VALIDATED, true); 463 notifyValidatedChangedAndDismissed(nc); 464 } 465 466 @Test testNetworkCapabilitiesUpdateWithFlag()467 public void testNetworkCapabilitiesUpdateWithFlag() throws Exception { 468 initActivity(TEST_URL); 469 final NetworkCapabilities nc = new NetworkCapabilities(); 470 nc.setCapability(NET_CAPABILITY_VALIDATED, true); 471 // Disable flag. Auto-dismiss should not happen. 472 setDismissPortalInValidatedNetwork(false); 473 notifyValidatedChangedNotDone(nc); 474 475 // Enable flag. Auto-dismissed. 476 setDismissPortalInValidatedNetwork(true); 477 notifyValidatedChangedAndDismissed(nc); 478 } 479 runCustomSchemeTest(String linkUri)480 private HttpServer runCustomSchemeTest(String linkUri) throws Exception { 481 final HttpServer server = new HttpServer(); 482 server.setResponseBody(TEST_URL_QUERY, 483 "<a id='tst_link' href='" + linkUri + "'>Test link</a>"); 484 485 server.start(); 486 ActivityScenario.launch(RequestDismissKeyguardActivity.class); 487 initActivity(server.makeUrl(TEST_URL_QUERY)); 488 // Mock all external intents 489 intending(not(isInternal())).respondWith(new ActivityResult(RESULT_OK, null)); 490 491 onWebView().withElement(findElement(Locator.ID, "tst_link")).perform(webClick()); 492 getInstrumentation().waitForIdleSync(); 493 return server; 494 } 495 496 @Test testTelScheme()497 public void testTelScheme() throws Exception { 498 final String telUri = "tel:0123456789"; 499 final HttpServer server = runCustomSchemeTest(telUri); 500 501 final Intent sentIntent = Intents.getIntents().get(0); 502 assertEquals(Intent.ACTION_DIAL, sentIntent.getAction()); 503 assertEquals(Uri.parse(telUri), sentIntent.getData()); 504 505 server.stop(); 506 } 507 508 @Test testSmsScheme()509 public void testSmsScheme() throws Exception { 510 final String telUri = "sms:0123456789"; 511 final HttpServer server = runCustomSchemeTest(telUri); 512 513 final Intent sentIntent = Intents.getIntents().get(0); 514 assertEquals(Intent.ACTION_SENDTO, sentIntent.getAction()); 515 assertEquals(Uri.parse(telUri), sentIntent.getData()); 516 517 server.stop(); 518 } 519 520 @Test testUnsupportedScheme()521 public void testUnsupportedScheme() throws Exception { 522 final HttpServer server = runCustomSchemeTest("mailto:test@example.com"); 523 assertEquals(0, Intents.getIntents().size()); 524 525 onWebView().withElement(findElement(Locator.ID, "continue_link")) 526 .perform(webClick()); 527 528 // The intent is sent in onDestroy(); there is no way to wait for that event, so poll 529 // until the intent is found. 530 assertTrue(isEventually(() -> Intents.getIntents().size() == 1, TEST_TIMEOUT_MS)); 531 verifyUseAsIs(); 532 final Intent sentIntent = Intents.getIntents().get(0); 533 assertEquals(Intent.ACTION_VIEW, sentIntent.getAction()); 534 assertEquals(Uri.parse(server.makeUrl(TEST_URL_QUERY)), sentIntent.getData()); 535 536 server.stop(); 537 } 538 539 @Test testDownload()540 public void testDownload() throws Exception { 541 // Setup the server with a single link on the portal page, leading to a download 542 final HttpServer server = new HttpServer(); 543 final String linkIdDownload = "download"; 544 final String downloadQuery = "dl"; 545 final String filename = "testfile.png"; 546 final String mimetype = "image/png"; 547 server.setResponseBody(TEST_URL_QUERY, 548 "<a id='" + linkIdDownload + "' href='?" + downloadQuery + "'>Download</a>"); 549 server.setResponse(downloadQuery, "This is a test file", mimetype, Collections.singletonMap( 550 "Content-Disposition", "attachment; filename=\"" + filename + "\"")); 551 server.start(); 552 553 ActivityScenario.launch(RequestDismissKeyguardActivity.class); 554 initActivity(server.makeUrl(TEST_URL_QUERY)); 555 556 // Create a mock file to be returned when mocking the file chooser 557 final Context ctx = mActivity.getApplicationContext(); 558 final Intent mockFileResponse = new Intent(); 559 final Uri mockFile = Uri.parse("content://mockdata"); 560 mockFileResponse.setData(mockFile); 561 562 // Mock file chooser and DownloadService intents 563 intending(hasAction(ACTION_CREATE_DOCUMENT)).respondWith( 564 new ActivityResult(RESULT_OK, mockFileResponse)); 565 // No intent fired yet 566 assertEquals(0, Intents.getIntents().size()); 567 568 onWebView().withElement(findElement(Locator.ID, linkIdDownload)) 569 .perform(webClick()); 570 571 // The create file intent should be fired when the download starts 572 assertTrue("Create file intent not received within timeout", 573 isEventually(() -> Intents.getIntents().size() == 1, TEST_TIMEOUT_MS)); 574 575 final Intent fileIntent = Intents.getIntents().get(0); 576 assertEquals(ACTION_CREATE_DOCUMENT, fileIntent.getAction()); 577 assertEquals(mimetype, fileIntent.getType()); 578 assertEquals(filename, fileIntent.getStringExtra(Intent.EXTRA_TITLE)); 579 580 // The download intent should be fired after the create file result is received 581 final Intent dlIntent = mActivity.mForegroundServiceStart.get( 582 TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS); 583 assertEquals(DownloadService.class.getName(), dlIntent.getComponent().getClassName()); 584 assertEquals(mNetwork, dlIntent.getParcelableExtra(DownloadService.ARG_NETWORK)); 585 assertEquals(TEST_USERAGENT, dlIntent.getStringExtra(DownloadService.ARG_USERAGENT)); 586 final String expectedUrl = server.makeUrl(downloadQuery); 587 assertEquals(expectedUrl, dlIntent.getStringExtra(DownloadService.ARG_URL)); 588 assertEquals(filename, dlIntent.getStringExtra(DownloadService.ARG_DISPLAY_NAME)); 589 assertEquals(mockFile, dlIntent.getParcelableExtra(DownloadService.ARG_OUTFILE)); 590 591 server.stop(); 592 } 593 594 @Test testVenueFriendlyNameTitle()595 public void testVenueFriendlyNameTitle() throws Exception { 596 assumeTrue(isAtLeastS()); 597 final LinkProperties linkProperties = new LinkProperties(); 598 CaptivePortalData.Builder captivePortalDataBuilder = new CaptivePortalData.Builder(); 599 // TODO: Use reflection for setVenueFriendlyName until shims are available 600 final Class captivePortalDataBuilderClass = captivePortalDataBuilder.getClass(); 601 final Method setVenueFriendlyNameMethod; 602 603 setVenueFriendlyNameMethod = captivePortalDataBuilderClass.getDeclaredMethod( 604 "setVenueFriendlyName", CharSequence.class); 605 606 captivePortalDataBuilder = (CaptivePortalData.Builder) 607 setVenueFriendlyNameMethod.invoke(captivePortalDataBuilder, TEST_FRIENDLY_NAME); 608 609 final CaptivePortalData captivePortalData = captivePortalDataBuilder.build(); 610 linkProperties.setCaptivePortalData(captivePortalData); 611 612 when(sConnectivityManager.getLinkProperties(mNetwork)).thenReturn(linkProperties); 613 configNonVpnNetwork(); 614 initActivity("https://tc.example.com/"); 615 616 // Verify that the correct venue friendly name is used 617 assertEquals(getInstrumentation().getContext().getString(R.string.action_bar_title, 618 TEST_FRIENDLY_NAME), mActivity.getActionBar().getTitle()); 619 } 620 621 @Test @SdkSuppress(maxSdkVersion = Build.VERSION_CODES.Q) testWifiSsid_Q()622 public void testWifiSsid_Q() throws Exception { 623 configNonVpnNetwork(); 624 initActivity("https://portal.example.com/"); 625 assertEquals(mActivity.getActionBar().getTitle(), 626 getInstrumentation().getContext().getString(R.string.action_bar_title, 627 TEST_WIFIINFO_SSID)); 628 } 629 630 @Test @SdkSuppress(minSdkVersion = Build.VERSION_CODES.R) testWifiSsid()631 public void testWifiSsid() throws Exception { 632 configNonVpnNetwork(); 633 initActivity("https://portal.example.com/"); 634 assertEquals(mActivity.getActionBar().getTitle(), 635 getInstrumentation().getContext().getString(R.string.action_bar_title, 636 TEST_NC_SSID)); 637 } 638 639 /** 640 * Check whether the device release or development API level is strictly higher than the passed 641 * in level. 642 * 643 * @return True if the device supports an SDK that has or will have a higher version number, 644 * even if still in development. 645 */ isReleaseOrDevelopmentApiAbove(int apiLevel)646 private static boolean isReleaseOrDevelopmentApiAbove(int apiLevel) { 647 // In-development API after n may have SDK_INT == n and CODENAME != REL 648 // Stable API n has SDK_INT == n and CODENAME == REL. 649 final int devApiLevel = Build.VERSION.SDK_INT 650 + ("REL".equals(Build.VERSION.CODENAME) ? 0 : 1); 651 return devApiLevel > apiLevel; 652 } 653 654 /** 655 * Check whether the device supports in-development or final S networking APIs. 656 */ isAtLeastS()657 private static boolean isAtLeastS() { 658 return isReleaseOrDevelopmentApiAbove(Build.VERSION_CODES.R); 659 } 660 isEventually(BooleanSupplier condition, long timeout)661 private static boolean isEventually(BooleanSupplier condition, long timeout) 662 throws InterruptedException { 663 final long start = System.currentTimeMillis(); 664 do { 665 if (condition.getAsBoolean()) return true; 666 Thread.sleep(10); 667 } while ((System.currentTimeMillis() - start) < timeout); 668 669 return false; 670 } 671 672 private static class HttpServer extends NanoHTTPD { 673 private final ServerSocket mSocket; 674 // Responses per URL query 675 private final HashMap<String, MockResponse> mResponses = new HashMap<>(); 676 677 private static final class MockResponse { 678 private final String mBody; 679 private final String mMimetype; 680 private final Map<String, String> mHeaders; 681 MockResponse(String body, String mimetype, Map<String, String> headers)682 MockResponse(String body, String mimetype, Map<String, String> headers) { 683 this.mBody = body; 684 this.mMimetype = mimetype; 685 this.mHeaders = Collections.unmodifiableMap(new HashMap<>(headers)); 686 } 687 } 688 HttpServer()689 HttpServer() throws IOException { 690 this(new ServerSocket()); 691 } 692 HttpServer(ServerSocket socket)693 private HttpServer(ServerSocket socket) { 694 // 0 as port for picking a port automatically 695 super("localhost", 0); 696 mSocket = socket; 697 } 698 699 @Override getServerSocketFactory()700 public ServerSocketFactory getServerSocketFactory() { 701 return () -> mSocket; 702 } 703 makeUrl(String query)704 private String makeUrl(String query) { 705 return new Uri.Builder() 706 .scheme("http") 707 .encodedAuthority("localhost:" + mSocket.getLocalPort()) 708 // Explicitly specify an empty path to match the format of URLs returned by 709 // WebView (for example in onDownloadStart) 710 .path("/") 711 .query(query) 712 .build() 713 .toString(); 714 } 715 setResponseBody(String query, String body)716 private void setResponseBody(String query, String body) { 717 setResponse(query, body, NanoHTTPD.MIME_HTML, Collections.emptyMap()); 718 } 719 setResponse(String query, String body, String mimetype, Map<String, String> headers)720 private void setResponse(String query, String body, String mimetype, 721 Map<String, String> headers) { 722 mResponses.put(query, new MockResponse(body, mimetype, headers)); 723 } 724 725 @Override serve(IHTTPSession session)726 public Response serve(IHTTPSession session) { 727 final MockResponse mockResponse = mResponses.get(session.getQueryParameterString()); 728 if (mockResponse == null) { 729 // Default response is a 404 730 return super.serve(session); 731 } 732 733 final Response response = newFixedLengthResponse(Response.Status.OK, 734 mockResponse.mMimetype, 735 "<!doctype html>" 736 + "<html>" 737 + "<head><title>Test portal</title></head>" 738 + "<body>" + mockResponse.mBody + "</body>" 739 + "</html>"); 740 mockResponse.mHeaders.forEach(response::addHeader); 741 return response; 742 } 743 } 744 } 745