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