• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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