1 /*
2  * Copyright (C) 2014 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.net.ConnectivityManager.EXTRA_CAPTIVE_PORTAL_PROBE_SPEC;
20 import static android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED;
21 import static android.provider.DeviceConfig.NAMESPACE_CONNECTIVITY;
22 
23 import android.app.Activity;
24 import android.app.AlertDialog;
25 import android.app.Application;
26 import android.app.admin.DevicePolicyManager;
27 import android.content.ActivityNotFoundException;
28 import android.content.ComponentName;
29 import android.content.Context;
30 import android.content.DialogInterface;
31 import android.content.Intent;
32 import android.content.pm.PackageManager.NameNotFoundException;
33 import android.graphics.Bitmap;
34 import android.net.CaptivePortal;
35 import android.net.ConnectivityManager;
36 import android.net.ConnectivityManager.NetworkCallback;
37 import android.net.Network;
38 import android.net.NetworkCapabilities;
39 import android.net.NetworkRequest;
40 import android.net.Proxy;
41 import android.net.Uri;
42 import android.net.captiveportal.CaptivePortalProbeSpec;
43 import android.net.http.SslCertificate;
44 import android.net.http.SslError;
45 import android.net.wifi.WifiInfo;
46 import android.net.wifi.WifiManager;
47 import android.os.Build;
48 import android.os.Bundle;
49 import android.os.SystemProperties;
50 import android.provider.DeviceConfig;
51 import android.provider.MediaStore;
52 import android.text.TextUtils;
53 import android.util.ArrayMap;
54 import android.util.ArraySet;
55 import android.util.Log;
56 import android.util.SparseArray;
57 import android.util.TypedValue;
58 import android.view.LayoutInflater;
59 import android.view.Menu;
60 import android.view.MenuItem;
61 import android.view.View;
62 import android.webkit.CookieManager;
63 import android.webkit.DownloadListener;
64 import android.webkit.SslErrorHandler;
65 import android.webkit.URLUtil;
66 import android.webkit.WebChromeClient;
67 import android.webkit.WebResourceRequest;
68 import android.webkit.WebResourceResponse;
69 import android.webkit.WebSettings;
70 import android.webkit.WebView;
71 import android.webkit.WebViewClient;
72 import android.widget.LinearLayout;
73 import android.widget.ProgressBar;
74 import android.widget.TextView;
75 
76 import androidx.annotation.GuardedBy;
77 import androidx.annotation.NonNull;
78 import androidx.annotation.StringRes;
79 import androidx.annotation.VisibleForTesting;
80 import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
81 
82 import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
83 
84 import java.io.IOException;
85 import java.lang.reflect.Field;
86 import java.lang.reflect.Method;
87 import java.net.HttpURLConnection;
88 import java.net.MalformedURLException;
89 import java.net.URL;
90 import java.net.URLConnection;
91 import java.util.Objects;
92 import java.util.Random;
93 import java.util.concurrent.atomic.AtomicBoolean;
94 
95 public class CaptivePortalLoginActivity extends Activity {
96     private static final String TAG = CaptivePortalLoginActivity.class.getSimpleName();
97     private static final boolean DBG = true;
98     private static final boolean VDBG = false;
99 
100     private static final int SOCKET_TIMEOUT_MS = 10000;
101     public static final String HTTP_LOCATION_HEADER_NAME = "Location";
102     private static final String DEFAULT_CAPTIVE_PORTAL_HTTP_URL =
103             "http://connectivitycheck.gstatic.com/generate_204";
104     public static final String DISMISS_PORTAL_IN_VALIDATED_NETWORK =
105             "dismiss_portal_in_validated_network";
106 
107     private enum Result {
108         DISMISSED(MetricsEvent.ACTION_CAPTIVE_PORTAL_LOGIN_RESULT_DISMISSED),
109         UNWANTED(MetricsEvent.ACTION_CAPTIVE_PORTAL_LOGIN_RESULT_UNWANTED),
110         WANTED_AS_IS(MetricsEvent.ACTION_CAPTIVE_PORTAL_LOGIN_RESULT_WANTED_AS_IS);
111 
112         final int metricsEvent;
Result(int metricsEvent)113         Result(int metricsEvent) { this.metricsEvent = metricsEvent; }
114     };
115 
116     private URL mUrl;
117     private CaptivePortalProbeSpec mProbeSpec;
118     private String mUserAgent;
119     private Network mNetwork;
120     @VisibleForTesting
121     protected CaptivePortal mCaptivePortal;
122     private NetworkCallback mNetworkCallback;
123     private ConnectivityManager mCm;
124     private DevicePolicyManager mDpm;
125     private WifiManager mWifiManager;
126     private boolean mLaunchBrowser = false;
127     private MyWebViewClient mWebViewClient;
128     private SwipeRefreshLayout mSwipeRefreshLayout;
129     // Ensures that done() happens once exactly, handling concurrent callers with atomic operations.
130     private final AtomicBoolean isDone = new AtomicBoolean(false);
131 
132     // When starting downloads a file is created via startActivityForResult(ACTION_CREATE_DOCUMENT).
133     // This array keeps the download request until the activity result is received. It is keyed by
134     // requestCode sent in startActivityForResult.
135     @GuardedBy("mDownloadRequests")
136     private final SparseArray<DownloadRequest> mDownloadRequests = new SparseArray<>();
137     @GuardedBy("mDownloadRequests")
138     private int mNextDownloadRequestId = 1;
139 
140     private static final class DownloadRequest {
141         final String mUrl;
142         final String mFilename;
DownloadRequest(String url, String filename)143         DownloadRequest(String url, String filename) {
144             mUrl = url;
145             mFilename = filename;
146         }
147     }
148 
149     @Override
onCreate(Bundle savedInstanceState)150     protected void onCreate(Bundle savedInstanceState) {
151         super.onCreate(savedInstanceState);
152         mCaptivePortal = getIntent().getParcelableExtra(ConnectivityManager.EXTRA_CAPTIVE_PORTAL);
153         logMetricsEvent(MetricsEvent.ACTION_CAPTIVE_PORTAL_LOGIN_ACTIVITY);
154         mCm = getSystemService(ConnectivityManager.class);
155         mDpm = getSystemService(DevicePolicyManager.class);
156         mWifiManager = getSystemService(WifiManager.class);
157         mNetwork = getIntent().getParcelableExtra(ConnectivityManager.EXTRA_NETWORK);
158         mUserAgent =
159                 getIntent().getStringExtra(ConnectivityManager.EXTRA_CAPTIVE_PORTAL_USER_AGENT);
160         mUrl = getUrl();
161         if (mUrl == null) {
162             // getUrl() failed to parse the url provided in the intent: bail out in a way that
163             // at least provides network access.
164             done(Result.WANTED_AS_IS);
165             return;
166         }
167         if (DBG) {
168             Log.d(TAG, String.format("onCreate for %s", mUrl));
169         }
170 
171         final String spec = getIntent().getStringExtra(EXTRA_CAPTIVE_PORTAL_PROBE_SPEC);
172         try {
173             mProbeSpec = CaptivePortalProbeSpec.parseSpecOrNull(spec);
174         } catch (Exception e) {
175             // Make extra sure that invalid configurations do not cause crashes
176             mProbeSpec = null;
177         }
178 
179         mNetworkCallback = new NetworkCallback() {
180             @Override
181             public void onLost(Network lostNetwork) {
182                 // If the network disappears while the app is up, exit.
183                 if (mNetwork.equals(lostNetwork)) done(Result.UNWANTED);
184             }
185 
186             @Override
187             public void onCapabilitiesChanged(Network network, NetworkCapabilities nc) {
188                 handleCapabilitiesChanged(network, nc);
189             }
190         };
191         mCm.registerNetworkCallback(new NetworkRequest.Builder().build(), mNetworkCallback);
192 
193         // If the network has disappeared, exit.
194         final NetworkCapabilities networkCapabilities = mCm.getNetworkCapabilities(mNetwork);
195         if (networkCapabilities == null) {
196             finishAndRemoveTask();
197             return;
198         }
199 
200         // Also initializes proxy system properties.
201         mNetwork = mNetwork.getPrivateDnsBypassingCopy();
202         mCm.bindProcessToNetwork(mNetwork);
203 
204         // Proxy system properties must be initialized before setContentView is called because
205         // setContentView initializes the WebView logic which in turn reads the system properties.
206         setContentView(R.layout.activity_captive_portal_login);
207 
208         getActionBar().setDisplayShowHomeEnabled(false);
209         getActionBar().setElevation(0); // remove shadow
210         getActionBar().setTitle(getHeaderTitle());
211         getActionBar().setSubtitle("");
212 
213         final WebView webview = getWebview();
214         webview.clearCache(true);
215         CookieManager.getInstance().setAcceptThirdPartyCookies(webview, true);
216         WebSettings webSettings = webview.getSettings();
217         webSettings.setJavaScriptEnabled(true);
218         webSettings.setMixedContentMode(WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE);
219         webSettings.setUseWideViewPort(true);
220         webSettings.setLoadWithOverviewMode(true);
221         webSettings.setSupportZoom(true);
222         webSettings.setBuiltInZoomControls(true);
223         webSettings.setDisplayZoomControls(false);
224         webSettings.setDomStorageEnabled(true);
225         mWebViewClient = new MyWebViewClient();
226         webview.setWebViewClient(mWebViewClient);
227         webview.setWebChromeClient(new MyWebChromeClient());
228         webview.setDownloadListener(new PortalDownloadListener());
229         // Start initial page load so WebView finishes loading proxy settings.
230         // Actual load of mUrl is initiated by MyWebViewClient.
231         webview.loadData("", "text/html", null);
232 
233         mSwipeRefreshLayout = findViewById(R.id.swipe_refresh);
234         mSwipeRefreshLayout.setOnRefreshListener(() -> {
235                 webview.reload();
236                 mSwipeRefreshLayout.setRefreshing(true);
237             });
238     }
239 
240     @VisibleForTesting
getWebViewClient()241     MyWebViewClient getWebViewClient() {
242         return mWebViewClient;
243     }
244 
245     @VisibleForTesting
handleCapabilitiesChanged(@onNull final Network network, @NonNull final NetworkCapabilities nc)246     void handleCapabilitiesChanged(@NonNull final Network network,
247             @NonNull final NetworkCapabilities nc) {
248         if (!isFeatureEnabled(DISMISS_PORTAL_IN_VALIDATED_NETWORK, isDismissPortalEnabled())) {
249             return;
250         }
251 
252         if (network.equals(mNetwork) && nc.hasCapability(NET_CAPABILITY_VALIDATED)) {
253             // Dismiss when login is no longer needed since network has validated, exit.
254             done(Result.DISMISSED);
255         }
256     }
257 
isDismissPortalEnabled()258     private boolean isDismissPortalEnabled() {
259         return Build.VERSION.SDK_INT > Build.VERSION_CODES.Q
260                 || (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q
261                 && !"REL".equals(Build.VERSION.CODENAME));
262     }
263 
264     // Find WebView's proxy BroadcastReceiver and prompt it to read proxy system properties.
setWebViewProxy()265     private void setWebViewProxy() {
266         // TODO: migrate to androidx WebView proxy setting API as soon as it is finalized
267         try {
268             final Field loadedApkField = Application.class.getDeclaredField("mLoadedApk");
269             final Class<?> loadedApkClass = loadedApkField.getType();
270             final Object loadedApk = loadedApkField.get(getApplication());
271             Field receiversField = loadedApkClass.getDeclaredField("mReceivers");
272             receiversField.setAccessible(true);
273             ArrayMap receivers = (ArrayMap) receiversField.get(loadedApk);
274             for (Object receiverMap : receivers.values()) {
275                 for (Object rec : ((ArrayMap) receiverMap).keySet()) {
276                     Class clazz = rec.getClass();
277                     if (clazz.getName().contains("ProxyChangeListener")) {
278                         Method onReceiveMethod = clazz.getDeclaredMethod("onReceive", Context.class,
279                                 Intent.class);
280                         Intent intent = new Intent(Proxy.PROXY_CHANGE_ACTION);
281                         onReceiveMethod.invoke(rec, getApplicationContext(), intent);
282                         Log.v(TAG, "Prompting WebView proxy reload.");
283                     }
284                 }
285             }
286         } catch (Exception e) {
287             Log.e(TAG, "Exception while setting WebView proxy: " + e);
288         }
289     }
290 
done(Result result)291     private void done(Result result) {
292         if (isDone.getAndSet(true)) {
293             // isDone was already true: done() already called
294             return;
295         }
296         if (DBG) {
297             Log.d(TAG, String.format("Result %s for %s", result.name(), mUrl));
298         }
299         logMetricsEvent(result.metricsEvent);
300         switch (result) {
301             case DISMISSED:
302                 mCaptivePortal.reportCaptivePortalDismissed();
303                 break;
304             case UNWANTED:
305                 mCaptivePortal.ignoreNetwork();
306                 break;
307             case WANTED_AS_IS:
308                 mCaptivePortal.useNetwork();
309                 break;
310         }
311         finishAndRemoveTask();
312     }
313 
314     @Override
onCreateOptionsMenu(Menu menu)315     public boolean onCreateOptionsMenu(Menu menu) {
316         getMenuInflater().inflate(R.menu.captive_portal_login, menu);
317         return true;
318     }
319 
320     @Override
onBackPressed()321     public void onBackPressed() {
322         WebView myWebView = findViewById(R.id.webview);
323         if (myWebView.canGoBack() && mWebViewClient.allowBack()) {
324             myWebView.goBack();
325         } else {
326             super.onBackPressed();
327         }
328     }
329 
330     @Override
onOptionsItemSelected(MenuItem item)331     public boolean onOptionsItemSelected(MenuItem item) {
332         final Result result;
333         final String action;
334         final int id = item.getItemId();
335         // This can't be a switch case because resource will be declared as static only but not
336         // static final as of ADT 14 in a library project. See
337         // http://tools.android.com/tips/non-constant-fields.
338         if (id == R.id.action_use_network) {
339             result = Result.WANTED_AS_IS;
340             action = "USE_NETWORK";
341         } else if (id == R.id.action_do_not_use_network) {
342             result = Result.UNWANTED;
343             action = "DO_NOT_USE_NETWORK";
344         } else {
345             return super.onOptionsItemSelected(item);
346         }
347         if (DBG) {
348             Log.d(TAG, String.format("onOptionsItemSelect %s for %s", action, mUrl));
349         }
350         done(result);
351         return true;
352     }
353 
354     @Override
onDestroy()355     public void onDestroy() {
356         super.onDestroy();
357         final WebView webview = (WebView) findViewById(R.id.webview);
358         if (webview != null) {
359             webview.stopLoading();
360             webview.setWebViewClient(null);
361             webview.setWebChromeClient(null);
362             webview.destroy();
363         }
364         if (mNetworkCallback != null) {
365             // mNetworkCallback is not null if mUrl is not null.
366             mCm.unregisterNetworkCallback(mNetworkCallback);
367         }
368         if (mLaunchBrowser) {
369             // Give time for this network to become default. After 500ms just proceed.
370             for (int i = 0; i < 5; i++) {
371                 // TODO: This misses when mNetwork underlies a VPN.
372                 if (mNetwork.equals(mCm.getActiveNetwork())) break;
373                 try {
374                     Thread.sleep(100);
375                 } catch (InterruptedException e) {
376                 }
377             }
378             final String url = mUrl.toString();
379             if (DBG) {
380                 Log.d(TAG, "starting activity with intent ACTION_VIEW for " + url);
381             }
382             startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url)));
383         }
384     }
385 
386     @Override
onActivityResult(int requestCode, int resultCode, Intent data)387     protected void onActivityResult(int requestCode, int resultCode, Intent data) {
388         if (resultCode != RESULT_OK || data == null) return;
389 
390         // Start download after receiving a created file to download to
391         final DownloadRequest pendingRequest;
392         synchronized (mDownloadRequests) {
393             pendingRequest = mDownloadRequests.get(requestCode);
394             if (pendingRequest == null) {
395                 Log.e(TAG, "No pending download for request " + requestCode);
396                 return;
397             }
398             mDownloadRequests.remove(requestCode);
399         }
400 
401         final Uri fileUri = data.getData();
402         if (fileUri == null) {
403             Log.e(TAG, "No file received from download file creation result");
404             return;
405         }
406 
407         final Intent downloadIntent = DownloadService.makeDownloadIntent(getApplicationContext(),
408                 mNetwork, mUserAgent, pendingRequest.mUrl, pendingRequest.mFilename, fileUri);
409 
410         startForegroundService(downloadIntent);
411     }
412 
getUrl()413     private URL getUrl() {
414         String url = getIntent().getStringExtra(ConnectivityManager.EXTRA_CAPTIVE_PORTAL_URL);
415         if (url == null) { // TODO: Have a metric to know how often empty url happened.
416             // ConnectivityManager#getCaptivePortalServerUrl is deprecated starting with Android R.
417             if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {
418                 url = DEFAULT_CAPTIVE_PORTAL_HTTP_URL;
419             } else {
420                 url = mCm.getCaptivePortalServerUrl();
421             }
422         }
423         return makeURL(url);
424     }
425 
makeURL(String url)426     private static URL makeURL(String url) {
427         try {
428             return new URL(url);
429         } catch (MalformedURLException e) {
430             Log.e(TAG, "Invalid URL " + url);
431         }
432         return null;
433     }
434 
host(URL url)435     private static String host(URL url) {
436         if (url == null) {
437             return null;
438         }
439         return url.getHost();
440     }
441 
sanitizeURL(URL url)442     private static String sanitizeURL(URL url) {
443         // In non-Debug build, only show host to avoid leaking private info.
444         return isDebuggable() ? Objects.toString(url) : host(url);
445     }
446 
isDebuggable()447     private static boolean isDebuggable() {
448         return SystemProperties.getInt("ro.debuggable", 0) == 1;
449     }
450 
reevaluateNetwork()451     private void reevaluateNetwork() {
452         if (isFeatureEnabled(DISMISS_PORTAL_IN_VALIDATED_NETWORK, isDismissPortalEnabled())) {
453             // TODO : replace this with an actual call to the method when the network stack
454             // is built against a recent enough SDK.
455             if (callVoidMethodIfExists(mCaptivePortal, "reevaluateNetwork")) return;
456         }
457         testForCaptivePortal();
458     }
459 
callVoidMethodIfExists(@onNull final Object target, @NonNull final String methodName)460     private boolean callVoidMethodIfExists(@NonNull final Object target,
461             @NonNull final String methodName) {
462         try {
463             final Method method = target.getClass().getDeclaredMethod(methodName);
464             method.invoke(target);
465             return true;
466         } catch (ReflectiveOperationException e) {
467             return false;
468         }
469     }
470 
testForCaptivePortal()471     private void testForCaptivePortal() {
472         // TODO: reuse NetworkMonitor facilities for consistent captive portal detection.
473         new Thread(new Runnable() {
474             public void run() {
475                 // Give time for captive portal to open.
476                 try {
477                     Thread.sleep(1000);
478                 } catch (InterruptedException e) {
479                 }
480                 HttpURLConnection urlConnection = null;
481                 int httpResponseCode = 500;
482                 String locationHeader = null;
483                 try {
484                     urlConnection = (HttpURLConnection) mNetwork.openConnection(mUrl);
485                     urlConnection.setInstanceFollowRedirects(false);
486                     urlConnection.setConnectTimeout(SOCKET_TIMEOUT_MS);
487                     urlConnection.setReadTimeout(SOCKET_TIMEOUT_MS);
488                     urlConnection.setUseCaches(false);
489                     if (mUserAgent != null) {
490                        urlConnection.setRequestProperty("User-Agent", mUserAgent);
491                     }
492                     // cannot read request header after connection
493                     String requestHeader = urlConnection.getRequestProperties().toString();
494 
495                     urlConnection.getInputStream();
496                     httpResponseCode = urlConnection.getResponseCode();
497                     locationHeader = urlConnection.getHeaderField(HTTP_LOCATION_HEADER_NAME);
498                     if (DBG) {
499                         Log.d(TAG, "probe at " + mUrl +
500                                 " ret=" + httpResponseCode +
501                                 " request=" + requestHeader +
502                                 " headers=" + urlConnection.getHeaderFields());
503                     }
504                 } catch (IOException e) {
505                 } finally {
506                     if (urlConnection != null) urlConnection.disconnect();
507                 }
508                 if (isDismissed(httpResponseCode, locationHeader, mProbeSpec)) {
509                     done(Result.DISMISSED);
510                 }
511             }
512         }).start();
513     }
514 
isDismissed( int httpResponseCode, String locationHeader, CaptivePortalProbeSpec probeSpec)515     private static boolean isDismissed(
516             int httpResponseCode, String locationHeader, CaptivePortalProbeSpec probeSpec) {
517         return (probeSpec != null)
518                 ? probeSpec.getResult(httpResponseCode, locationHeader).isSuccessful()
519                 : (httpResponseCode == 204);
520     }
521 
522     @VisibleForTesting
hasVpnNetwork()523     boolean hasVpnNetwork() {
524         for (Network network : mCm.getAllNetworks()) {
525             final NetworkCapabilities nc = mCm.getNetworkCapabilities(network);
526             if (nc != null && nc.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
527                 return true;
528             }
529         }
530 
531         return false;
532     }
533 
534     @VisibleForTesting
isAlwaysOnVpnEnabled()535     boolean isAlwaysOnVpnEnabled() {
536         final ComponentName cn = new ComponentName(this, CaptivePortalLoginActivity.class);
537         return mDpm.isAlwaysOnVpnLockdownEnabled(cn);
538     }
539 
540     @VisibleForTesting
541     class MyWebViewClient extends WebViewClient {
542         private static final String INTERNAL_ASSETS = "file:///android_asset/";
543 
544         private final String mBrowserBailOutToken = Long.toString(new Random().nextLong());
545         private final String mCertificateOutToken = Long.toString(new Random().nextLong());
546         // How many Android device-independent-pixels per scaled-pixel
547         // dp/sp = (px/sp) / (px/dp) = (1/sp) / (1/dp)
548         private final float mDpPerSp = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 1,
549                     getResources().getDisplayMetrics()) /
550                     TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1,
551                     getResources().getDisplayMetrics());
552         private int mPagesLoaded;
553         private final ArraySet<String> mMainFrameUrls = new ArraySet<>();
554 
555         // If we haven't finished cleaning up the history, don't allow going back.
allowBack()556         public boolean allowBack() {
557             return mPagesLoaded > 1;
558         }
559 
560         private String mSslErrorTitle = null;
561         private SslErrorHandler mSslErrorHandler = null;
562         private SslError mSslError = null;
563 
564         @Override
onPageStarted(WebView view, String urlString, Bitmap favicon)565         public void onPageStarted(WebView view, String urlString, Bitmap favicon) {
566             if (urlString.contains(mBrowserBailOutToken)) {
567                 mLaunchBrowser = true;
568                 done(Result.WANTED_AS_IS);
569                 return;
570             }
571             // The first page load is used only to cause the WebView to
572             // fetch the proxy settings.  Don't update the URL bar, and
573             // don't check if the captive portal is still there.
574             if (mPagesLoaded == 0) {
575                 return;
576             }
577             final URL url = makeURL(urlString);
578             Log.d(TAG, "onPageStarted: " + sanitizeURL(url));
579             // For internally generated pages, leave URL bar listing prior URL as this is the URL
580             // the page refers to.
581             if (!urlString.startsWith(INTERNAL_ASSETS)) {
582                 String subtitle = (url != null) ? getHeaderSubtitle(url) : urlString;
583                 getActionBar().setSubtitle(subtitle);
584             }
585             getProgressBar().setVisibility(View.VISIBLE);
586             reevaluateNetwork();
587         }
588 
589         @Override
onPageFinished(WebView view, String url)590         public void onPageFinished(WebView view, String url) {
591             mPagesLoaded++;
592             getProgressBar().setVisibility(View.INVISIBLE);
593             mSwipeRefreshLayout.setRefreshing(false);
594             if (mPagesLoaded == 1) {
595                 // Now that WebView has loaded at least one page we know it has read in the proxy
596                 // settings.  Now prompt the WebView read the Network-specific proxy settings.
597                 setWebViewProxy();
598                 // Load the real page.
599                 view.loadUrl(mUrl.toString());
600                 return;
601             } else if (mPagesLoaded == 2) {
602                 // Prevent going back to empty first page.
603                 // Fix for missing focus, see b/62449959 for details. Remove it once we get a
604                 // newer version of WebView (60.x.y).
605                 view.requestFocus();
606                 view.clearHistory();
607             }
608             reevaluateNetwork();
609         }
610 
611         // Convert Android scaled-pixels (sp) to HTML size.
sp(int sp)612         private String sp(int sp) {
613             // Convert sp to dp's.
614             float dp = sp * mDpPerSp;
615             // Apply a scale factor to make things look right.
616             dp *= 1.3;
617             // Convert dp's to HTML size.
618             // HTML px's are scaled just like dp's, so just add "px" suffix.
619             return Integer.toString((int)dp) + "px";
620         }
621 
622         // Check if webview is trying to load the main frame and record its url.
623         @Override
shouldOverrideUrlLoading(WebView view, WebResourceRequest request)624         public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
625             final String url = request.getUrl().toString();
626             if (request.isForMainFrame()) {
627                 mMainFrameUrls.add(url);
628             }
629             // Be careful that two shouldOverrideUrlLoading methods are overridden, but
630             // shouldOverrideUrlLoading(WebView view, String url) was deprecated in API level 24.
631             // TODO: delete deprecated one ??
632             return shouldOverrideUrlLoading(view, url);
633         }
634 
635         // Record the initial main frame url. This is only called for the initial resource URL, not
636         // any subsequent redirect URLs.
637         @Override
shouldInterceptRequest(WebView view, WebResourceRequest request)638         public WebResourceResponse shouldInterceptRequest(WebView view,
639                 WebResourceRequest request) {
640             if (request.isForMainFrame()) {
641                 mMainFrameUrls.add(request.getUrl().toString());
642             }
643             return null;
644         }
645 
646         // A web page consisting of a large broken lock icon to indicate SSL failure.
647         @Override
onReceivedSslError(WebView view, SslErrorHandler handler, SslError error)648         public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) {
649             final String strErrorUrl = error.getUrl();
650             final URL errorUrl = makeURL(strErrorUrl);
651             Log.d(TAG, String.format("SSL error: %s, url: %s, certificate: %s",
652                     sslErrorName(error), sanitizeURL(errorUrl), error.getCertificate()));
653             if (errorUrl == null
654                     // Ignore SSL errors coming from subresources by comparing the
655                     // main frame urls with SSL error url.
656                     || (!mMainFrameUrls.contains(strErrorUrl))) {
657                 handler.cancel();
658                 return;
659             }
660             logMetricsEvent(MetricsEvent.CAPTIVE_PORTAL_LOGIN_ACTIVITY_SSL_ERROR);
661             final String sslErrorPage = makeSslErrorPage();
662             view.loadDataWithBaseURL(INTERNAL_ASSETS, sslErrorPage, "text/HTML", "UTF-8", null);
663             mSslErrorTitle = view.getTitle() == null ? "" : view.getTitle();
664             mSslErrorHandler = handler;
665             mSslError = error;
666         }
667 
makeHtmlTag()668         private String makeHtmlTag() {
669             if (getWebview().getLayoutDirection() == View.LAYOUT_DIRECTION_RTL) {
670                 return "<html dir=\"rtl\">";
671             }
672 
673             return "<html>";
674         }
675 
676         // If there is a VPN network or always-on VPN is enabled, there may be no way for user to
677         // see the log-in page by browser. So, hide the link which is used to open the browser.
678         @VisibleForTesting
getVpnMsgOrLinkToBrowser()679         String getVpnMsgOrLinkToBrowser() {
680             if (isAlwaysOnVpnEnabled() || hasVpnNetwork()) {
681                 final String vpnWarning = getString(R.string.no_bypass_error_vpnwarning);
682                 return "  <div class=vpnwarning>" + vpnWarning + "</div><br>";
683             }
684 
685             final String continueMsg = getString(R.string.error_continue_via_browser);
686             return "  <a id=continue_link href=" + mBrowserBailOutToken + ">" + continueMsg
687                     + "</a><br>";
688         }
689 
makeErrorPage(@tringRes int warningMsgRes, @StringRes int exampleMsgRes, String extraLink)690         private String makeErrorPage(@StringRes int warningMsgRes, @StringRes int exampleMsgRes,
691                 String extraLink) {
692             final String warningMsg = getString(warningMsgRes);
693             final String exampleMsg = getString(exampleMsgRes);
694             return String.join("\n",
695                     makeHtmlTag(),
696                     "<head>",
697                     "  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
698                     "  <style>",
699                     "    body {",
700                     "      background-color:#fafafa;",
701                     "      margin:auto;",
702                     "      width:80%;",
703                     "      margin-top: 96px",
704                     "    }",
705                     "    img {",
706                     "      height:48px;",
707                     "      width:48px;",
708                     "    }",
709                     "    div.warn {",
710                     "      font-size:" + sp(16) + ";",
711                     "      line-height:1.28;",
712                     "      margin-top:16px;",
713                     "      opacity:0.87;",
714                     "    }",
715                     "    div.example, div.vpnwarning {",
716                     "      font-size:" + sp(14) + ";",
717                     "      line-height:1.21905;",
718                     "      margin-top:16px;",
719                     "      opacity:0.54;",
720                     "    }",
721                     "    a {",
722                     "      color:#4285F4;",
723                     "      display:inline-block;",
724                     "      font-size:" + sp(14) + ";",
725                     "      font-weight:bold;",
726                     "      height:48px;",
727                     "      margin-top:24px;",
728                     "      text-decoration:none;",
729                     "      text-transform:uppercase;",
730                     "    }",
731                     "    a#cert_link {",
732                     "      margin-top:0px;",
733                     "    }",
734                     "  </style>",
735                     "</head>",
736                     "<body>",
737                     "  <p><img src=quantum_ic_warning_amber_96.png><br>",
738                     "  <div class=warn>" + warningMsg + "</div>",
739                     "  <div class=example>" + exampleMsg + "</div>",
740                     getVpnMsgOrLinkToBrowser(),
741                     extraLink,
742                     "</body>",
743                     "</html>");
744         }
745 
makeCustomSchemeErrorPage()746         private String makeCustomSchemeErrorPage() {
747             return makeErrorPage(R.string.custom_scheme_warning, R.string.custom_scheme_example,
748                     "" /* extraLink */);
749         }
750 
makeSslErrorPage()751         private String makeSslErrorPage() {
752             final String certificateMsg = getString(R.string.ssl_error_view_certificate);
753             return makeErrorPage(R.string.ssl_error_warning, R.string.ssl_error_example,
754                     "<a id=cert_link href=" + mCertificateOutToken + ">" + certificateMsg
755                             + "</a>");
756         }
757 
758         @Override
shouldOverrideUrlLoading(WebView view, String url)759         public boolean shouldOverrideUrlLoading (WebView view, String url) {
760             if (url.startsWith("tel:")) {
761                 return startActivity(Intent.ACTION_DIAL, url);
762             } else if (url.startsWith("sms:")) {
763                 return startActivity(Intent.ACTION_SENDTO, url);
764             } else if (!url.startsWith("http:")
765                     && !url.startsWith("https:") && !url.startsWith(INTERNAL_ASSETS)) {
766                 // If the page is not in a supported scheme (HTTP, HTTPS or internal page),
767                 // show an error page that informs the user that the page is not supported. The
768                 // user can bypass the warning and reopen the portal in browser if needed.
769                 // This is done as it is unclear whether third party applications can properly
770                 // handle multinetwork scenarios, if the scheme refers to a third party application.
771                 loadCustomSchemeErrorPage(view);
772                 return true;
773             }
774             if (url.contains(mCertificateOutToken) && mSslError != null) {
775                 showSslAlertDialog(mSslErrorHandler, mSslError, mSslErrorTitle);
776                 return true;
777             }
778             return false;
779         }
780 
startActivity(String action, String uriData)781         private boolean startActivity(String action, String uriData) {
782             final Intent intent = new Intent(action, Uri.parse(uriData));
783             intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
784             try {
785                 CaptivePortalLoginActivity.this.startActivity(intent);
786                 return true;
787             } catch (ActivityNotFoundException e) {
788                 Log.e(TAG, "No activity found to handle captive portal intent", e);
789                 return false;
790             }
791         }
792 
loadCustomSchemeErrorPage(WebView view)793         protected void loadCustomSchemeErrorPage(WebView view) {
794             final String errorPage = makeCustomSchemeErrorPage();
795             view.loadDataWithBaseURL(INTERNAL_ASSETS, errorPage, "text/HTML", "UTF-8", null);
796         }
797 
showSslAlertDialog(SslErrorHandler handler, SslError error, String title)798         private void showSslAlertDialog(SslErrorHandler handler, SslError error, String title) {
799             final LayoutInflater factory = LayoutInflater.from(CaptivePortalLoginActivity.this);
800             final View sslWarningView = factory.inflate(R.layout.ssl_warning, null);
801 
802             // Set Security certificate
803             setViewSecurityCertificate(sslWarningView.findViewById(R.id.certificate_layout), error);
804             ((TextView) sslWarningView.findViewById(R.id.ssl_error_type))
805                     .setText(sslErrorName(error));
806             ((TextView) sslWarningView.findViewById(R.id.title)).setText(mSslErrorTitle);
807             ((TextView) sslWarningView.findViewById(R.id.address)).setText(error.getUrl());
808 
809             AlertDialog sslAlertDialog = new AlertDialog.Builder(CaptivePortalLoginActivity.this)
810                     .setTitle(R.string.ssl_security_warning_title)
811                     .setView(sslWarningView)
812                     .setPositiveButton(R.string.ok, (DialogInterface dialog, int whichButton) -> {
813                         // handler.cancel is called via OnCancelListener.
814                         dialog.cancel();
815                     })
816                     .setOnCancelListener((DialogInterface dialogInterface) -> handler.cancel())
817                     .create();
818             sslAlertDialog.show();
819         }
820 
setViewSecurityCertificate(LinearLayout certificateLayout, SslError error)821         private void setViewSecurityCertificate(LinearLayout certificateLayout, SslError error) {
822             ((TextView) certificateLayout.findViewById(R.id.ssl_error_msg))
823                     .setText(sslErrorMessage(error));
824             SslCertificate cert = error.getCertificate();
825             // TODO: call the method directly once inflateCertificateView is @SystemApi
826             try {
827                 final View certificateView = (View) SslCertificate.class.getMethod(
828                         "inflateCertificateView", Context.class)
829                         .invoke(cert, CaptivePortalLoginActivity.this);
830                 certificateLayout.addView(certificateView);
831             } catch (ReflectiveOperationException | SecurityException e) {
832                 Log.e(TAG, "Could not create certificate view", e);
833             }
834         }
835     }
836 
837     private class MyWebChromeClient extends WebChromeClient {
838         @Override
onProgressChanged(WebView view, int newProgress)839         public void onProgressChanged(WebView view, int newProgress) {
840             getProgressBar().setProgress(newProgress);
841         }
842     }
843 
844     private class PortalDownloadListener implements DownloadListener {
845         @Override
onDownloadStart(String url, String userAgent, String contentDisposition, String mimetype, long contentLength)846         public void onDownloadStart(String url, String userAgent, String contentDisposition,
847                 String mimetype, long contentLength) {
848             final String normalizedType = Intent.normalizeMimeType(mimetype);
849             final String displayName = URLUtil.guessFileName(url, contentDisposition,
850                     normalizedType);
851 
852             String guessedMimetype = normalizedType;
853             if (TextUtils.isEmpty(guessedMimetype)) {
854                 guessedMimetype = URLConnection.guessContentTypeFromName(displayName);
855             }
856             if (TextUtils.isEmpty(guessedMimetype)) {
857                 guessedMimetype = MediaStore.Downloads.CONTENT_TYPE;
858             }
859 
860             Log.d(TAG, String.format("Starting download for %s, type %s with display name %s",
861                     url, guessedMimetype, displayName));
862 
863             final Intent createFileIntent = DownloadService.makeCreateFileIntent(
864                     guessedMimetype, displayName);
865 
866             final int requestId;
867             // WebView should call onDownloadStart from the UI thread, but to be extra-safe as
868             // that is not documented behavior, access the download requests array with a lock.
869             synchronized (mDownloadRequests) {
870                 requestId = mNextDownloadRequestId++;
871                 mDownloadRequests.put(requestId, new DownloadRequest(url, displayName));
872             }
873 
874             try {
875                 startActivityForResult(createFileIntent, requestId);
876             } catch (ActivityNotFoundException e) {
877                 // This could happen in theory if the device has no stock document provider (which
878                 // Android normally requires), or if the user disabled all of them, but
879                 // should be rare; the download cannot be started as no writeable file can be
880                 // created.
881                 Log.e(TAG, "No document provider found to create download file", e);
882             }
883         }
884     }
885 
getProgressBar()886     private ProgressBar getProgressBar() {
887         return findViewById(R.id.progress_bar);
888     }
889 
getWebview()890     private WebView getWebview() {
891         return findViewById(R.id.webview);
892     }
893 
getHeaderTitle()894     private String getHeaderTitle() {
895         NetworkCapabilities nc = mCm.getNetworkCapabilities(mNetwork);
896         final String ssid = getSsid();
897         if (TextUtils.isEmpty(ssid)
898                 || nc == null || !nc.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
899             return getString(R.string.action_bar_label);
900         }
901         return getString(R.string.action_bar_title, ssid);
902     }
903 
904     // TODO: remove once SSID is obtained from NetworkCapabilities
getSsid()905     private String getSsid() {
906         if (mWifiManager == null) {
907             return null;
908         }
909         final WifiInfo wifiInfo = mWifiManager.getConnectionInfo();
910         return removeDoubleQuotes(wifiInfo.getSSID());
911     }
912 
removeDoubleQuotes(String string)913     private static String removeDoubleQuotes(String string) {
914         if (string == null) return null;
915         final int length = string.length();
916         if ((length > 1) && (string.charAt(0) == '"') && (string.charAt(length - 1) == '"')) {
917             return string.substring(1, length - 1);
918         }
919         return string;
920     }
921 
getHeaderSubtitle(URL url)922     private String getHeaderSubtitle(URL url) {
923         String host = host(url);
924         final String https = "https";
925         if (https.equals(url.getProtocol())) {
926             return https + "://" + host;
927         }
928         return host;
929     }
930 
logMetricsEvent(int event)931     private void logMetricsEvent(int event) {
932         mCaptivePortal.logEvent(event, getPackageName());
933     }
934 
935     private static final SparseArray<String> SSL_ERRORS = new SparseArray<>();
936     static {
SSL_ERRORS.put(SslError.SSL_NOTYETVALID, "SSL_NOTYETVALID")937         SSL_ERRORS.put(SslError.SSL_NOTYETVALID,  "SSL_NOTYETVALID");
SSL_ERRORS.put(SslError.SSL_EXPIRED, "SSL_EXPIRED")938         SSL_ERRORS.put(SslError.SSL_EXPIRED,      "SSL_EXPIRED");
SSL_ERRORS.put(SslError.SSL_IDMISMATCH, "SSL_IDMISMATCH")939         SSL_ERRORS.put(SslError.SSL_IDMISMATCH,   "SSL_IDMISMATCH");
SSL_ERRORS.put(SslError.SSL_UNTRUSTED, "SSL_UNTRUSTED")940         SSL_ERRORS.put(SslError.SSL_UNTRUSTED,    "SSL_UNTRUSTED");
SSL_ERRORS.put(SslError.SSL_DATE_INVALID, "SSL_DATE_INVALID")941         SSL_ERRORS.put(SslError.SSL_DATE_INVALID, "SSL_DATE_INVALID");
SSL_ERRORS.put(SslError.SSL_INVALID, "SSL_INVALID")942         SSL_ERRORS.put(SslError.SSL_INVALID,      "SSL_INVALID");
943     }
944 
sslErrorName(SslError error)945     private static String sslErrorName(SslError error) {
946         return SSL_ERRORS.get(error.getPrimaryError(), "UNKNOWN");
947     }
948 
949     private static final SparseArray<Integer> SSL_ERROR_MSGS = new SparseArray<>();
950     static {
SSL_ERROR_MSGS.put(SslError.SSL_NOTYETVALID, R.string.ssl_error_not_yet_valid)951         SSL_ERROR_MSGS.put(SslError.SSL_NOTYETVALID,  R.string.ssl_error_not_yet_valid);
SSL_ERROR_MSGS.put(SslError.SSL_EXPIRED, R.string.ssl_error_expired)952         SSL_ERROR_MSGS.put(SslError.SSL_EXPIRED,      R.string.ssl_error_expired);
SSL_ERROR_MSGS.put(SslError.SSL_IDMISMATCH, R.string.ssl_error_mismatch)953         SSL_ERROR_MSGS.put(SslError.SSL_IDMISMATCH,   R.string.ssl_error_mismatch);
SSL_ERROR_MSGS.put(SslError.SSL_UNTRUSTED, R.string.ssl_error_untrusted)954         SSL_ERROR_MSGS.put(SslError.SSL_UNTRUSTED,    R.string.ssl_error_untrusted);
SSL_ERROR_MSGS.put(SslError.SSL_DATE_INVALID, R.string.ssl_error_date_invalid)955         SSL_ERROR_MSGS.put(SslError.SSL_DATE_INVALID, R.string.ssl_error_date_invalid);
SSL_ERROR_MSGS.put(SslError.SSL_INVALID, R.string.ssl_error_invalid)956         SSL_ERROR_MSGS.put(SslError.SSL_INVALID,      R.string.ssl_error_invalid);
957     }
958 
sslErrorMessage(SslError error)959     private static Integer sslErrorMessage(SslError error) {
960         return SSL_ERROR_MSGS.get(error.getPrimaryError(), R.string.ssl_error_unknown);
961     }
962 
isFeatureEnabled(@onNull final String name, final boolean defaultEnabled)963     private boolean isFeatureEnabled(@NonNull final String name, final boolean defaultEnabled) {
964         final long propertyVersion = DeviceConfig.getLong(NAMESPACE_CONNECTIVITY, name, 0);
965         long mPackageVersion = 0;
966         try {
967             mPackageVersion = getPackageManager().getPackageInfo(
968                 getPackageName(), 0).getLongVersionCode();
969         } catch (NameNotFoundException e) {
970             Log.e(TAG, "Could not find the package name", e);
971         }
972         return (propertyVersion == 0 && defaultEnabled)
973                 || (propertyVersion != 0 && mPackageVersion >= propertyVersion);
974     }
975 }
976