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 
22 import static com.android.captiveportallogin.DownloadService.isDirectlyOpenType;
23 
24 import android.app.Activity;
25 import android.app.AlertDialog;
26 import android.app.Application;
27 import android.app.admin.DevicePolicyManager;
28 import android.content.ActivityNotFoundException;
29 import android.content.ComponentName;
30 import android.content.Context;
31 import android.content.DialogInterface;
32 import android.content.Intent;
33 import android.content.ServiceConnection;
34 import android.graphics.Bitmap;
35 import android.net.CaptivePortal;
36 import android.net.CaptivePortalData;
37 import android.net.ConnectivityManager;
38 import android.net.ConnectivityManager.NetworkCallback;
39 import android.net.LinkProperties;
40 import android.net.Network;
41 import android.net.NetworkCapabilities;
42 import android.net.NetworkRequest;
43 import android.net.Proxy;
44 import android.net.Uri;
45 import android.net.captiveportal.CaptivePortalProbeSpec;
46 import android.net.http.SslCertificate;
47 import android.net.http.SslError;
48 import android.net.wifi.WifiInfo;
49 import android.net.wifi.WifiManager;
50 import android.os.Build;
51 import android.os.Bundle;
52 import android.os.IBinder;
53 import android.os.Looper;
54 import android.os.SystemProperties;
55 import android.provider.DocumentsContract;
56 import android.provider.MediaStore;
57 import android.text.TextUtils;
58 import android.util.ArrayMap;
59 import android.util.ArraySet;
60 import android.util.Log;
61 import android.util.SparseArray;
62 import android.util.TypedValue;
63 import android.view.LayoutInflater;
64 import android.view.Menu;
65 import android.view.MenuItem;
66 import android.view.View;
67 import android.view.ViewGroup;
68 import android.webkit.CookieManager;
69 import android.webkit.DownloadListener;
70 import android.webkit.SslErrorHandler;
71 import android.webkit.URLUtil;
72 import android.webkit.WebChromeClient;
73 import android.webkit.WebResourceRequest;
74 import android.webkit.WebResourceResponse;
75 import android.webkit.WebSettings;
76 import android.webkit.WebView;
77 import android.webkit.WebViewClient;
78 import android.widget.FrameLayout;
79 import android.widget.LinearLayout;
80 import android.widget.ProgressBar;
81 import android.widget.TextView;
82 import android.widget.Toast;
83 
84 import androidx.annotation.GuardedBy;
85 import androidx.annotation.NonNull;
86 import androidx.annotation.Nullable;
87 import androidx.annotation.StringRes;
88 import androidx.annotation.VisibleForTesting;
89 import androidx.core.content.FileProvider;
90 import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
91 
92 import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
93 
94 import java.io.File;
95 import java.io.FileNotFoundException;
96 import java.io.IOException;
97 import java.lang.reflect.Field;
98 import java.lang.reflect.Method;
99 import java.net.HttpURLConnection;
100 import java.net.MalformedURLException;
101 import java.net.URL;
102 import java.net.URLConnection;
103 import java.nio.file.Files;
104 import java.nio.file.Path;
105 import java.nio.file.Paths;
106 import java.util.Objects;
107 import java.util.Random;
108 import java.util.concurrent.atomic.AtomicBoolean;
109 
110 public class CaptivePortalLoginActivity extends Activity {
111     private static final String TAG = CaptivePortalLoginActivity.class.getSimpleName();
112     private static final boolean DBG = true;
113     private static final boolean VDBG = false;
114 
115     private static final int SOCKET_TIMEOUT_MS = 10000;
116     public static final String HTTP_LOCATION_HEADER_NAME = "Location";
117     private static final String DEFAULT_CAPTIVE_PORTAL_HTTP_URL =
118             "http://connectivitycheck.gstatic.com/generate_204";
119     // This should match the FileProvider authority specified in the app manifest.
120     private static final String FILE_PROVIDER_AUTHORITY =
121             "com.android.captiveportallogin.fileprovider";
122     // This should match the path name in the FileProvider paths XML.
123     @VisibleForTesting
124     static final String FILE_PROVIDER_DOWNLOAD_PATH = "downloads";
125     private static final int NO_DIRECTLY_OPEN_TASK_ID = -1;
126     private enum Result {
127         DISMISSED(MetricsEvent.ACTION_CAPTIVE_PORTAL_LOGIN_RESULT_DISMISSED),
128         UNWANTED(MetricsEvent.ACTION_CAPTIVE_PORTAL_LOGIN_RESULT_UNWANTED),
129         WANTED_AS_IS(MetricsEvent.ACTION_CAPTIVE_PORTAL_LOGIN_RESULT_WANTED_AS_IS);
130 
131         final int metricsEvent;
Result(int metricsEvent)132         Result(int metricsEvent) { this.metricsEvent = metricsEvent; }
133     };
134 
135     private URL mUrl;
136     private CaptivePortalProbeSpec mProbeSpec;
137     private String mUserAgent;
138     private Network mNetwork;
139     private CharSequence mVenueFriendlyName = null;
140     @VisibleForTesting
141     protected CaptivePortal mCaptivePortal;
142     private NetworkCallback mNetworkCallback;
143     private ConnectivityManager mCm;
144     private DevicePolicyManager mDpm;
145     private WifiManager mWifiManager;
146     private boolean mLaunchBrowser = false;
147     private MyWebViewClient mWebViewClient;
148     private SwipeRefreshLayout mSwipeRefreshLayout;
149     // Ensures that done() happens once exactly, handling concurrent callers with atomic operations.
150     private final AtomicBoolean isDone = new AtomicBoolean(false);
151 
152     // When starting downloads a file is created via startActivityForResult(ACTION_CREATE_DOCUMENT).
153     // This array keeps the download request until the activity result is received. It is keyed by
154     // requestCode sent in startActivityForResult.
155     @GuardedBy("mDownloadRequests")
156     private final SparseArray<DownloadRequest> mDownloadRequests = new SparseArray<>();
157     @GuardedBy("mDownloadRequests")
158     private int mNextDownloadRequestId = 1;
159 
160     // mDownloadService and mDirectlyOpenId must be always updated from the main thread.
161     @VisibleForTesting
162     int mDirectlyOpenId = NO_DIRECTLY_OPEN_TASK_ID;
163     @Nullable
164     private DownloadService.DownloadServiceBinder mDownloadService = null;
165     private final ServiceConnection mDownloadServiceConn = new ServiceConnection() {
166         @Override
167         public void onServiceDisconnected(ComponentName name) {
168             Log.d(TAG, "Download service disconnected");
169             mDownloadService = null;
170             // Service binding is lost. The spinner for the directly open tasks is no longer
171             // needed.
172             setProgressSpinnerVisibility(View.GONE);
173         }
174 
175         @Override
176         public void onServiceConnected(ComponentName name, IBinder binder) {
177             Log.d(TAG, "Download service connected");
178             mDownloadService = (DownloadService.DownloadServiceBinder) binder;
179             mDownloadService.setProgressCallback(mProgressCallback);
180             maybeStartPendingDownloads();
181         }
182     };
183 
184     @VisibleForTesting
185     final DownloadService.ProgressCallback mProgressCallback =
186             new DownloadService.ProgressCallback() {
187         @Override
188         public void onDownloadComplete(Uri inputFile, String mimeType, int downloadId,
189                 boolean success) {
190             if (isDirectlyOpenType(mimeType) && success) {
191                 try {
192                     startActivity(makeDirectlyOpenIntent(inputFile, mimeType));
193                 } catch (ActivityNotFoundException e) {
194                     // Delete the directly open file if no activity could handle it. This is
195                     // verified before downloading, so it should only happen when the handling app
196                     // was uninstalled while downloading, which is vanishingly rare. Try to delete
197                     // it in case of the target activity being removed somehow.
198                     Log.wtf(TAG, "No activity could handle " + mimeType + " file.", e);
199                     runOnUiThread(() -> tryDeleteFile(inputFile));
200                 }
201             }
202 
203             verifyDownloadIdAndMaybeHideSpinner(downloadId);
204         }
205 
206         @Override
207         public void onDownloadAborted(int downloadId, int reason) {
208             if (reason == DownloadService.DOWNLOAD_ABORTED_REASON_FILE_TOO_LARGE) {
209                 runOnUiThread(() -> Toast.makeText(CaptivePortalLoginActivity.this,
210                         R.string.file_too_large_cancel_download, Toast.LENGTH_LONG).show());
211             }
212 
213             verifyDownloadIdAndMaybeHideSpinner(downloadId);
214         }
215 
216         private void verifyDownloadIdAndMaybeHideSpinner(int id) {
217             // Hide the spinner when the task completed signal for the target task is received.
218             //
219             // mDirectlyOpenId will not be updated until the existing directly open task is
220             // completed or the connection to the DownloadService is lost. If the id is updated to
221             // NO_DIRECTLY_OPEN_TASK_ID because of the loss of connection to DownloadService, the
222             // spinner should be already hidden. Receiving relevant callback is ignorable.
223             runOnUiThread(() -> {
224                 if (mDirectlyOpenId == id) setProgressSpinnerVisibility(View.GONE);
225             });
226         }
227     };
228 
maybeStartPendingDownloads()229     private void maybeStartPendingDownloads() {
230         ensureRunningOnMainThread();
231 
232         if (mDownloadService == null) return;
233         synchronized (mDownloadRequests) {
234             for (int i = 0; i < mDownloadRequests.size(); i++) {
235                 final DownloadRequest req = mDownloadRequests.valueAt(i);
236                 if (req.mOutFile == null) continue;
237 
238                 final int dlId = mDownloadService.requestDownload(mNetwork, mUserAgent, req.mUrl,
239                         req.mFilename, req.mOutFile, getApplicationContext(), req.mMimeType);
240                 if (isDirectlyOpenType(req.mMimeType)) {
241                     mDirectlyOpenId = dlId;
242                     setProgressSpinnerVisibility(View.VISIBLE);
243                 }
244 
245                 mDownloadRequests.removeAt(i);
246                 i--;
247             }
248         }
249     }
250 
makeDirectlyOpenIntent(Uri inputFile, String mimeType)251     private Intent makeDirectlyOpenIntent(Uri inputFile, String mimeType) {
252         return new Intent(Intent.ACTION_VIEW)
253                 .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
254                         | Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
255                 .setDataAndType(inputFile, mimeType);
256     }
257 
tryDeleteFile(@onNull Uri file)258     private void tryDeleteFile(@NonNull Uri file) {
259         ensureRunningOnMainThread();
260         try {
261             DocumentsContract.deleteDocument(getContentResolver(), file);
262         } catch (FileNotFoundException e) {
263             // Nothing to delete
264             Log.wtf(TAG, file + " not found for deleting");
265         }
266     }
267 
268     private static final class DownloadRequest {
269         @NonNull final String mUrl;
270         @NonNull final String mFilename;
271         @NonNull final String mMimeType;
272         // mOutFile is null for requests where the device is currently asking the user to pick a
273         // place to put the file. When the user has picked the file name, the request will be
274         // replaced by a new one with the correct file name in onActivityResult.
275         @Nullable final Uri mOutFile;
DownloadRequest(@onNull String url, @NonNull String filename, @NonNull String mimeType, @Nullable Uri outFile)276         DownloadRequest(@NonNull String url, @NonNull String filename, @NonNull String mimeType,
277                 @Nullable Uri outFile) {
278             mUrl = url;
279             mFilename = filename;
280             mMimeType = mimeType;
281             mOutFile = outFile;
282         }
283     }
284 
285     @Override
onCreate(Bundle savedInstanceState)286     protected void onCreate(Bundle savedInstanceState) {
287         super.onCreate(savedInstanceState);
288         mCaptivePortal = getIntent().getParcelableExtra(ConnectivityManager.EXTRA_CAPTIVE_PORTAL);
289         // Null CaptivePortal is unexpected. The following flow will need to access mCaptivePortal
290         // to communicate with system. Thus, finish the activity.
291         if (mCaptivePortal == null) {
292             Log.e(TAG, "Unexpected null CaptivePortal");
293             finish();
294             return;
295         }
296         mCm = getSystemService(ConnectivityManager.class);
297         mDpm = getSystemService(DevicePolicyManager.class);
298         mWifiManager = getSystemService(WifiManager.class);
299         mNetwork = getIntent().getParcelableExtra(ConnectivityManager.EXTRA_NETWORK);
300         mVenueFriendlyName = getVenueFriendlyName();
301         mUserAgent =
302                 getIntent().getStringExtra(ConnectivityManager.EXTRA_CAPTIVE_PORTAL_USER_AGENT);
303         mUrl = getUrl();
304         if (mUrl == null) {
305             // getUrl() failed to parse the url provided in the intent: bail out in a way that
306             // at least provides network access.
307             done(Result.WANTED_AS_IS);
308             return;
309         }
310         if (DBG) {
311             Log.d(TAG, String.format("onCreate for %s", mUrl));
312         }
313 
314         final String spec = getIntent().getStringExtra(EXTRA_CAPTIVE_PORTAL_PROBE_SPEC);
315         try {
316             mProbeSpec = CaptivePortalProbeSpec.parseSpecOrNull(spec);
317         } catch (Exception e) {
318             // Make extra sure that invalid configurations do not cause crashes
319             mProbeSpec = null;
320         }
321 
322         mNetworkCallback = new NetworkCallback() {
323             @Override
324             public void onLost(Network lostNetwork) {
325                 // If the network disappears while the app is up, exit.
326                 if (mNetwork.equals(lostNetwork)) done(Result.UNWANTED);
327             }
328 
329             @Override
330             public void onCapabilitiesChanged(Network network, NetworkCapabilities nc) {
331                 handleCapabilitiesChanged(network, nc);
332             }
333         };
334         mCm.registerNetworkCallback(new NetworkRequest.Builder().build(), mNetworkCallback);
335 
336         // If the network has disappeared, exit.
337         final NetworkCapabilities networkCapabilities = mCm.getNetworkCapabilities(mNetwork);
338         if (networkCapabilities == null) {
339             finishAndRemoveTask();
340             return;
341         }
342 
343         // Also initializes proxy system properties.
344         mNetwork = mNetwork.getPrivateDnsBypassingCopy();
345         mCm.bindProcessToNetwork(mNetwork);
346 
347         // Proxy system properties must be initialized before setContentView is called because
348         // setContentView initializes the WebView logic which in turn reads the system properties.
349         setContentView(R.layout.activity_captive_portal_login);
350 
351         getActionBar().setDisplayShowHomeEnabled(false);
352         getActionBar().setElevation(0); // remove shadow
353         getActionBar().setTitle(getHeaderTitle());
354         getActionBar().setSubtitle("");
355 
356         final WebView webview = getWebview();
357         webview.clearCache(true);
358         CookieManager.getInstance().setAcceptThirdPartyCookies(webview, true);
359         WebSettings webSettings = webview.getSettings();
360         webSettings.setJavaScriptEnabled(true);
361         webSettings.setMixedContentMode(WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE);
362         webSettings.setUseWideViewPort(true);
363         webSettings.setLoadWithOverviewMode(true);
364         webSettings.setSupportZoom(true);
365         webSettings.setBuiltInZoomControls(true);
366         webSettings.setDisplayZoomControls(false);
367         webSettings.setDomStorageEnabled(true);
368         mWebViewClient = new MyWebViewClient();
369         webview.setWebViewClient(mWebViewClient);
370         webview.setWebChromeClient(new MyWebChromeClient());
371         webview.setDownloadListener(new PortalDownloadListener());
372         // Start initial page load so WebView finishes loading proxy settings.
373         // Actual load of mUrl is initiated by MyWebViewClient.
374         webview.loadData("", "text/html", null);
375 
376         mSwipeRefreshLayout = findViewById(R.id.swipe_refresh);
377         mSwipeRefreshLayout.setOnRefreshListener(() -> {
378                 webview.reload();
379                 mSwipeRefreshLayout.setRefreshing(true);
380             });
381 
382         maybeDeleteDirectlyOpenFile();
383     }
384 
maybeDeleteDirectlyOpenFile()385     private void maybeDeleteDirectlyOpenFile() {
386         // Try to remove the directly open files if exists.
387         final File downloadPath = new File(getFilesDir(), FILE_PROVIDER_DOWNLOAD_PATH);
388         try {
389             deleteRecursively(downloadPath);
390         } catch (IOException e) {
391             Log.d(TAG, "Exception while deleting temp download files", e);
392         }
393     }
394 
deleteRecursively(final File path)395     private static boolean deleteRecursively(final File path) throws IOException {
396         if (path.isDirectory()) {
397             final File[] files = path.listFiles();
398             if (files != null) {
399                 for (final File child : files) {
400                     deleteRecursively(child);
401                 }
402             }
403         }
404         final Path parsedPath = Paths.get(path.toURI());
405         Log.d(TAG, "Cleaning up " + parsedPath);
406         return Files.deleteIfExists(parsedPath);
407     }
408 
409     @VisibleForTesting
getWebViewClient()410     MyWebViewClient getWebViewClient() {
411         return mWebViewClient;
412     }
413 
414     @VisibleForTesting
handleCapabilitiesChanged(@onNull final Network network, @NonNull final NetworkCapabilities nc)415     void handleCapabilitiesChanged(@NonNull final Network network,
416             @NonNull final NetworkCapabilities nc) {
417         if (!isNetworkValidationDismissEnabled()) {
418             return;
419         }
420 
421         if (network.equals(mNetwork) && nc.hasCapability(NET_CAPABILITY_VALIDATED)) {
422             // Dismiss when login is no longer needed since network has validated, exit.
423             done(Result.DISMISSED);
424         }
425     }
426 
427     /**
428      * Indicates whether network validation (NET_CAPABILITY_VALIDATED) should be used to determine
429      * when the portal should be dismissed, instead of having the CaptivePortalLoginActivity use
430      * its own probe.
431      */
isNetworkValidationDismissEnabled()432     private boolean isNetworkValidationDismissEnabled() {
433         return isAtLeastR();
434     }
435 
isAtLeastR()436     private boolean isAtLeastR() {
437         return Build.VERSION.SDK_INT > Build.VERSION_CODES.Q;
438     }
439 
440     // Find WebView's proxy BroadcastReceiver and prompt it to read proxy system properties.
setWebViewProxy()441     private void setWebViewProxy() {
442         // TODO: migrate to androidx WebView proxy setting API as soon as it is finalized
443         try {
444             final Field loadedApkField = Application.class.getDeclaredField("mLoadedApk");
445             final Class<?> loadedApkClass = loadedApkField.getType();
446             final Object loadedApk = loadedApkField.get(getApplication());
447             Field receiversField = loadedApkClass.getDeclaredField("mReceivers");
448             receiversField.setAccessible(true);
449             ArrayMap receivers = (ArrayMap) receiversField.get(loadedApk);
450             for (Object receiverMap : receivers.values()) {
451                 for (Object rec : ((ArrayMap) receiverMap).keySet()) {
452                     Class clazz = rec.getClass();
453                     if (clazz.getName().contains("ProxyChangeListener")) {
454                         Method onReceiveMethod = clazz.getDeclaredMethod("onReceive", Context.class,
455                                 Intent.class);
456                         Intent intent = new Intent(Proxy.PROXY_CHANGE_ACTION);
457                         onReceiveMethod.invoke(rec, getApplicationContext(), intent);
458                         Log.v(TAG, "Prompting WebView proxy reload.");
459                     }
460                 }
461             }
462         } catch (Exception e) {
463             Log.e(TAG, "Exception while setting WebView proxy: " + e);
464         }
465     }
466 
done(Result result)467     private void done(Result result) {
468         if (isDone.getAndSet(true)) {
469             // isDone was already true: done() already called
470             return;
471         }
472         if (DBG) {
473             Log.d(TAG, String.format("Result %s for %s", result.name(), mUrl));
474         }
475         switch (result) {
476             case DISMISSED:
477                 mCaptivePortal.reportCaptivePortalDismissed();
478                 break;
479             case UNWANTED:
480                 mCaptivePortal.ignoreNetwork();
481                 break;
482             case WANTED_AS_IS:
483                 mCaptivePortal.useNetwork();
484                 break;
485         }
486         finishAndRemoveTask();
487     }
488 
489     @Override
onCreateOptionsMenu(Menu menu)490     public boolean onCreateOptionsMenu(Menu menu) {
491         getMenuInflater().inflate(R.menu.captive_portal_login, menu);
492         return true;
493     }
494 
495     @Override
onBackPressed()496     public void onBackPressed() {
497         WebView myWebView = findViewById(R.id.webview);
498         if (myWebView.canGoBack() && mWebViewClient.allowBack()) {
499             myWebView.goBack();
500         } else {
501             super.onBackPressed();
502         }
503     }
504 
505     @Override
onOptionsItemSelected(MenuItem item)506     public boolean onOptionsItemSelected(MenuItem item) {
507         final Result result;
508         final String action;
509         final int id = item.getItemId();
510         // This can't be a switch case because resource will be declared as static only but not
511         // static final as of ADT 14 in a library project. See
512         // http://tools.android.com/tips/non-constant-fields.
513         if (id == R.id.action_use_network) {
514             result = Result.WANTED_AS_IS;
515             action = "USE_NETWORK";
516         } else if (id == R.id.action_do_not_use_network) {
517             result = Result.UNWANTED;
518             action = "DO_NOT_USE_NETWORK";
519         } else {
520             return super.onOptionsItemSelected(item);
521         }
522         if (DBG) {
523             Log.d(TAG, String.format("onOptionsItemSelect %s for %s", action, mUrl));
524         }
525         done(result);
526         return true;
527     }
528 
529     @Override
onStop()530     public void onStop() {
531         super.onStop();
532         cancelPendingTask();
533     }
534 
535     // This must be always called from main thread.
setProgressSpinnerVisibility(int visibility)536     private void setProgressSpinnerVisibility(int visibility) {
537         ensureRunningOnMainThread();
538 
539         getProgressLayout().setVisibility(visibility);
540         if (visibility != View.VISIBLE) {
541             mDirectlyOpenId = NO_DIRECTLY_OPEN_TASK_ID;
542         }
543     }
544 
545     @VisibleForTesting
cancelPendingTask()546     void cancelPendingTask() {
547         ensureRunningOnMainThread();
548         if (mDirectlyOpenId != NO_DIRECTLY_OPEN_TASK_ID) {
549             Toast.makeText(this, R.string.cancel_pending_downloads, Toast.LENGTH_SHORT).show();
550             // Remove the pending task for downloading the directly open file.
551             mDownloadService.cancelTask(mDirectlyOpenId);
552         }
553     }
554 
ensureRunningOnMainThread()555     private void ensureRunningOnMainThread() {
556         if (Looper.getMainLooper().getThread() != Thread.currentThread()) {
557             throw new IllegalStateException(
558                     "Not running on main thread: " + Thread.currentThread().getName());
559         }
560     }
561 
562     @Override
onDestroy()563     public void onDestroy() {
564         super.onDestroy();
565 
566         if (mDownloadService != null) {
567             unbindService(mDownloadServiceConn);
568         }
569 
570         final WebView webview = (WebView) findViewById(R.id.webview);
571         if (webview != null) {
572             webview.stopLoading();
573             webview.setWebViewClient(null);
574             webview.setWebChromeClient(null);
575             // According to the doc of WebView#destroy(), webview should be removed from the view
576             // system before calling the WebView#destroy().
577             ((ViewGroup) webview.getParent()).removeView(webview);
578             webview.destroy();
579         }
580         if (mNetworkCallback != null) {
581             // mNetworkCallback is not null if mUrl is not null.
582             mCm.unregisterNetworkCallback(mNetworkCallback);
583         }
584         if (mLaunchBrowser) {
585             // Give time for this network to become default. After 500ms just proceed.
586             for (int i = 0; i < 5; i++) {
587                 // TODO: This misses when mNetwork underlies a VPN.
588                 if (mNetwork.equals(mCm.getActiveNetwork())) break;
589                 try {
590                     Thread.sleep(100);
591                 } catch (InterruptedException e) {
592                 }
593             }
594             final String url = mUrl.toString();
595             if (DBG) {
596                 Log.d(TAG, "starting activity with intent ACTION_VIEW for " + url);
597             }
598             startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url)));
599         }
600     }
601 
602     @Override
onActivityResult(int requestCode, int resultCode, Intent data)603     protected void onActivityResult(int requestCode, int resultCode, Intent data) {
604         if (resultCode != RESULT_OK || data == null) return;
605 
606         // Start download after receiving a created file to download to
607         final DownloadRequest pendingRequest;
608         synchronized (mDownloadRequests) {
609             pendingRequest = mDownloadRequests.get(requestCode);
610             if (pendingRequest == null) {
611                 Log.e(TAG, "No pending download for request " + requestCode);
612                 return;
613             }
614         }
615 
616         final Uri fileUri = data.getData();
617         if (fileUri == null) {
618             Log.e(TAG, "No file received from download file creation result");
619             return;
620         }
621 
622         synchronized (mDownloadRequests) {
623             // Replace the pending request with file uri in mDownloadRequests.
624             mDownloadRequests.put(requestCode, new DownloadRequest(pendingRequest.mUrl,
625                     pendingRequest.mFilename, pendingRequest.mMimeType, fileUri));
626         }
627         maybeStartPendingDownloads();
628     }
629 
getUrl()630     private URL getUrl() {
631         String url = getIntent().getStringExtra(ConnectivityManager.EXTRA_CAPTIVE_PORTAL_URL);
632         if (url == null) { // TODO: Have a metric to know how often empty url happened.
633             // ConnectivityManager#getCaptivePortalServerUrl is deprecated starting with Android R.
634             if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {
635                 url = DEFAULT_CAPTIVE_PORTAL_HTTP_URL;
636             } else {
637                 url = mCm.getCaptivePortalServerUrl();
638             }
639         }
640         return makeURL(url);
641     }
642 
makeURL(String url)643     private static URL makeURL(String url) {
644         try {
645             return new URL(url);
646         } catch (MalformedURLException e) {
647             Log.e(TAG, "Invalid URL " + url);
648         }
649         return null;
650     }
651 
host(URL url)652     private static String host(URL url) {
653         if (url == null) {
654             return null;
655         }
656         return url.getHost();
657     }
658 
sanitizeURL(URL url)659     private static String sanitizeURL(URL url) {
660         // In non-Debug build, only show host to avoid leaking private info.
661         return isDebuggable() ? Objects.toString(url) : host(url);
662     }
663 
isDebuggable()664     private static boolean isDebuggable() {
665         return SystemProperties.getInt("ro.debuggable", 0) == 1;
666     }
667 
reevaluateNetwork()668     private void reevaluateNetwork() {
669         if (isNetworkValidationDismissEnabled()) {
670             // TODO : replace this with an actual call to the method when the network stack
671             // is built against a recent enough SDK.
672             if (callVoidMethodIfExists(mCaptivePortal, "reevaluateNetwork")) return;
673         }
674         testForCaptivePortal();
675     }
676 
callVoidMethodIfExists(@onNull final Object target, @NonNull final String methodName)677     private boolean callVoidMethodIfExists(@NonNull final Object target,
678             @NonNull final String methodName) {
679         try {
680             final Method method = target.getClass().getDeclaredMethod(methodName);
681             method.invoke(target);
682             return true;
683         } catch (ReflectiveOperationException e) {
684             return false;
685         }
686     }
687 
testForCaptivePortal()688     private void testForCaptivePortal() {
689         // TODO: NetworkMonitor validation is used on R+ instead; remove when dropping Q support.
690         new Thread(new Runnable() {
691             public void run() {
692                 // Give time for captive portal to open.
693                 try {
694                     Thread.sleep(1000);
695                 } catch (InterruptedException e) {
696                 }
697                 HttpURLConnection urlConnection = null;
698                 int httpResponseCode = 500;
699                 String locationHeader = null;
700                 try {
701                     urlConnection = (HttpURLConnection) mNetwork.openConnection(mUrl);
702                     urlConnection.setInstanceFollowRedirects(false);
703                     urlConnection.setConnectTimeout(SOCKET_TIMEOUT_MS);
704                     urlConnection.setReadTimeout(SOCKET_TIMEOUT_MS);
705                     urlConnection.setUseCaches(false);
706                     if (mUserAgent != null) {
707                        urlConnection.setRequestProperty("User-Agent", mUserAgent);
708                     }
709                     // cannot read request header after connection
710                     String requestHeader = urlConnection.getRequestProperties().toString();
711 
712                     urlConnection.getInputStream();
713                     httpResponseCode = urlConnection.getResponseCode();
714                     locationHeader = urlConnection.getHeaderField(HTTP_LOCATION_HEADER_NAME);
715                     if (DBG) {
716                         Log.d(TAG, "probe at " + mUrl +
717                                 " ret=" + httpResponseCode +
718                                 " request=" + requestHeader +
719                                 " headers=" + urlConnection.getHeaderFields());
720                     }
721                 } catch (IOException e) {
722                 } finally {
723                     if (urlConnection != null) urlConnection.disconnect();
724                 }
725                 if (isDismissed(httpResponseCode, locationHeader, mProbeSpec)) {
726                     done(Result.DISMISSED);
727                 }
728             }
729         }).start();
730     }
731 
isDismissed( int httpResponseCode, String locationHeader, CaptivePortalProbeSpec probeSpec)732     private static boolean isDismissed(
733             int httpResponseCode, String locationHeader, CaptivePortalProbeSpec probeSpec) {
734         return (probeSpec != null)
735                 ? probeSpec.getResult(httpResponseCode, locationHeader).isSuccessful()
736                 : (httpResponseCode == 204);
737     }
738 
739     @VisibleForTesting
hasVpnNetwork()740     boolean hasVpnNetwork() {
741         for (Network network : mCm.getAllNetworks()) {
742             final NetworkCapabilities nc = mCm.getNetworkCapabilities(network);
743             if (nc != null && nc.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
744                 return true;
745             }
746         }
747 
748         return false;
749     }
750 
751     @VisibleForTesting
isAlwaysOnVpnEnabled()752     boolean isAlwaysOnVpnEnabled() {
753         final ComponentName cn = new ComponentName(this, CaptivePortalLoginActivity.class);
754         return mDpm.isAlwaysOnVpnLockdownEnabled(cn);
755     }
756 
757     @VisibleForTesting
758     class MyWebViewClient extends WebViewClient {
759         private static final String INTERNAL_ASSETS = "file:///android_asset/";
760 
761         private final String mBrowserBailOutToken = Long.toString(new Random().nextLong());
762         private final String mCertificateOutToken = Long.toString(new Random().nextLong());
763         // How many Android device-independent-pixels per scaled-pixel
764         // dp/sp = (px/sp) / (px/dp) = (1/sp) / (1/dp)
765         private final float mDpPerSp = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 1,
766                     getResources().getDisplayMetrics()) /
767                     TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1,
768                     getResources().getDisplayMetrics());
769         private int mPagesLoaded;
770         private final ArraySet<String> mMainFrameUrls = new ArraySet<>();
771 
772         // If we haven't finished cleaning up the history, don't allow going back.
allowBack()773         public boolean allowBack() {
774             return mPagesLoaded > 1;
775         }
776 
777         private String mSslErrorTitle = null;
778         private SslErrorHandler mSslErrorHandler = null;
779         private SslError mSslError = null;
780 
781         @Override
onPageStarted(WebView view, String urlString, Bitmap favicon)782         public void onPageStarted(WebView view, String urlString, Bitmap favicon) {
783             if (urlString.contains(mBrowserBailOutToken)) {
784                 mLaunchBrowser = true;
785                 done(Result.WANTED_AS_IS);
786                 return;
787             }
788             // The first page load is used only to cause the WebView to
789             // fetch the proxy settings.  Don't update the URL bar, and
790             // don't check if the captive portal is still there.
791             if (mPagesLoaded == 0) {
792                 return;
793             }
794             final URL url = makeURL(urlString);
795             Log.d(TAG, "onPageStarted: " + sanitizeURL(url));
796             // For internally generated pages, leave URL bar listing prior URL as this is the URL
797             // the page refers to.
798             if (!urlString.startsWith(INTERNAL_ASSETS)) {
799                 String subtitle = (url != null) ? getHeaderSubtitle(url) : urlString;
800                 getActionBar().setSubtitle(subtitle);
801             }
802             getProgressBar().setVisibility(View.VISIBLE);
803             reevaluateNetwork();
804         }
805 
806         @Override
onPageFinished(WebView view, String url)807         public void onPageFinished(WebView view, String url) {
808             mPagesLoaded++;
809             getProgressBar().setVisibility(View.INVISIBLE);
810             mSwipeRefreshLayout.setRefreshing(false);
811             if (mPagesLoaded == 1) {
812                 // Now that WebView has loaded at least one page we know it has read in the proxy
813                 // settings.  Now prompt the WebView read the Network-specific proxy settings.
814                 setWebViewProxy();
815                 // Load the real page.
816                 view.loadUrl(mUrl.toString());
817                 return;
818             } else if (mPagesLoaded == 2) {
819                 // Prevent going back to empty first page.
820                 // Fix for missing focus, see b/62449959 for details. Remove it once we get a
821                 // newer version of WebView (60.x.y).
822                 view.requestFocus();
823                 view.clearHistory();
824             }
825             reevaluateNetwork();
826         }
827 
828         // Convert Android scaled-pixels (sp) to HTML size.
sp(int sp)829         private String sp(int sp) {
830             // Convert sp to dp's.
831             float dp = sp * mDpPerSp;
832             // Apply a scale factor to make things look right.
833             dp *= 1.3;
834             // Convert dp's to HTML size.
835             // HTML px's are scaled just like dp's, so just add "px" suffix.
836             return Integer.toString((int)dp) + "px";
837         }
838 
839         // Check if webview is trying to load the main frame and record its url.
840         @Override
shouldOverrideUrlLoading(WebView view, WebResourceRequest request)841         public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
842             final String url = request.getUrl().toString();
843             if (request.isForMainFrame()) {
844                 mMainFrameUrls.add(url);
845             }
846             // Be careful that two shouldOverrideUrlLoading methods are overridden, but
847             // shouldOverrideUrlLoading(WebView view, String url) was deprecated in API level 24.
848             // TODO: delete deprecated one ??
849             return shouldOverrideUrlLoading(view, url);
850         }
851 
852         // Record the initial main frame url. This is only called for the initial resource URL, not
853         // any subsequent redirect URLs.
854         @Override
shouldInterceptRequest(WebView view, WebResourceRequest request)855         public WebResourceResponse shouldInterceptRequest(WebView view,
856                 WebResourceRequest request) {
857             if (request.isForMainFrame()) {
858                 mMainFrameUrls.add(request.getUrl().toString());
859             }
860             return null;
861         }
862 
863         // A web page consisting of a large broken lock icon to indicate SSL failure.
864         @Override
onReceivedSslError(WebView view, SslErrorHandler handler, SslError error)865         public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) {
866             final String strErrorUrl = error.getUrl();
867             final URL errorUrl = makeURL(strErrorUrl);
868             Log.d(TAG, String.format("SSL error: %s, url: %s, certificate: %s",
869                     sslErrorName(error), sanitizeURL(errorUrl), error.getCertificate()));
870             if (errorUrl == null
871                     // Ignore SSL errors coming from subresources by comparing the
872                     // main frame urls with SSL error url.
873                     || (!mMainFrameUrls.contains(strErrorUrl))) {
874                 handler.cancel();
875                 return;
876             }
877             final String sslErrorPage = makeSslErrorPage();
878             view.loadDataWithBaseURL(INTERNAL_ASSETS, sslErrorPage, "text/HTML", "UTF-8", null);
879             mSslErrorTitle = view.getTitle() == null ? "" : view.getTitle();
880             mSslErrorHandler = handler;
881             mSslError = error;
882         }
883 
makeHtmlTag()884         private String makeHtmlTag() {
885             if (getWebview().getLayoutDirection() == View.LAYOUT_DIRECTION_RTL) {
886                 return "<html dir=\"rtl\">";
887             }
888 
889             return "<html>";
890         }
891 
892         // If there is a VPN network or always-on VPN is enabled, there may be no way for user to
893         // see the log-in page by browser. So, hide the link which is used to open the browser.
894         @VisibleForTesting
getVpnMsgOrLinkToBrowser()895         String getVpnMsgOrLinkToBrowser() {
896             // Before Android R, CaptivePortalLogin cannot call the isAlwaysOnVpnLockdownEnabled()
897             // to get the status of VPN always-on due to permission denied. So adding a version
898             // check here to prevent CaptivePortalLogin crashes.
899             if (hasVpnNetwork() || (isAtLeastR() && isAlwaysOnVpnEnabled())) {
900                 final String vpnWarning = getString(R.string.no_bypass_error_vpnwarning);
901                 return "  <div class=vpnwarning>" + vpnWarning + "</div><br>";
902             }
903 
904             final String continueMsg = getString(R.string.error_continue_via_browser);
905             return "  <a id=continue_link href=" + mBrowserBailOutToken + ">" + continueMsg
906                     + "</a><br>";
907         }
908 
makeErrorPage(@tringRes int warningMsgRes, @StringRes int exampleMsgRes, String extraLink)909         private String makeErrorPage(@StringRes int warningMsgRes, @StringRes int exampleMsgRes,
910                 String extraLink) {
911             final String warningMsg = getString(warningMsgRes);
912             final String exampleMsg = getString(exampleMsgRes);
913             return String.join("\n",
914                     makeHtmlTag(),
915                     "<head>",
916                     "  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
917                     "  <style>",
918                     "    body {",
919                     "      background-color:#fafafa;",
920                     "      margin:auto;",
921                     "      width:80%;",
922                     "      margin-top: 96px",
923                     "    }",
924                     "    img {",
925                     "      height:48px;",
926                     "      width:48px;",
927                     "    }",
928                     "    div.warn {",
929                     "      font-size:" + sp(16) + ";",
930                     "      line-height:1.28;",
931                     "      margin-top:16px;",
932                     "      opacity:0.87;",
933                     "    }",
934                     "    div.example, div.vpnwarning {",
935                     "      font-size:" + sp(14) + ";",
936                     "      line-height:1.21905;",
937                     "      margin-top:16px;",
938                     "      opacity:0.54;",
939                     "    }",
940                     "    a {",
941                     "      color:#4285F4;",
942                     "      display:inline-block;",
943                     "      font-size:" + sp(14) + ";",
944                     "      font-weight:bold;",
945                     "      margin-top:24px;",
946                     "      text-decoration:none;",
947                     "      text-transform:uppercase;",
948                     "    }",
949                     "  </style>",
950                     "</head>",
951                     "<body>",
952                     "  <p><img src=quantum_ic_warning_amber_96.png><br>",
953                     "  <div class=warn>" + warningMsg + "</div>",
954                     "  <div class=example>" + exampleMsg + "</div>",
955                     getVpnMsgOrLinkToBrowser(),
956                     extraLink,
957                     "</body>",
958                     "</html>");
959         }
960 
makeCustomSchemeErrorPage()961         private String makeCustomSchemeErrorPage() {
962             return makeErrorPage(R.string.custom_scheme_warning, R.string.custom_scheme_example,
963                     "" /* extraLink */);
964         }
965 
makeSslErrorPage()966         private String makeSslErrorPage() {
967             final String certificateMsg = getString(R.string.ssl_error_view_certificate);
968             return makeErrorPage(R.string.ssl_error_warning, R.string.ssl_error_example,
969                     "<a id=cert_link href=" + mCertificateOutToken + ">" + certificateMsg
970                             + "</a>");
971         }
972 
973         @Override
shouldOverrideUrlLoading(WebView view, String url)974         public boolean shouldOverrideUrlLoading (WebView view, String url) {
975             if (url.startsWith("tel:")) {
976                 return startActivity(Intent.ACTION_DIAL, url);
977             } else if (url.startsWith("sms:")) {
978                 return startActivity(Intent.ACTION_SENDTO, url);
979             } else if (!url.startsWith("http:")
980                     && !url.startsWith("https:") && !url.startsWith(INTERNAL_ASSETS)) {
981                 // If the page is not in a supported scheme (HTTP, HTTPS or internal page),
982                 // show an error page that informs the user that the page is not supported. The
983                 // user can bypass the warning and reopen the portal in browser if needed.
984                 // This is done as it is unclear whether third party applications can properly
985                 // handle multinetwork scenarios, if the scheme refers to a third party application.
986                 loadCustomSchemeErrorPage(view);
987                 return true;
988             }
989             if (url.contains(mCertificateOutToken) && mSslError != null) {
990                 showSslAlertDialog(mSslErrorHandler, mSslError, mSslErrorTitle);
991                 return true;
992             }
993             return false;
994         }
995 
startActivity(String action, String uriData)996         private boolean startActivity(String action, String uriData) {
997             final Intent intent = new Intent(action, Uri.parse(uriData));
998             intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
999             try {
1000                 CaptivePortalLoginActivity.this.startActivity(intent);
1001                 return true;
1002             } catch (ActivityNotFoundException e) {
1003                 Log.e(TAG, "No activity found to handle captive portal intent", e);
1004                 return false;
1005             }
1006         }
1007 
loadCustomSchemeErrorPage(WebView view)1008         protected void loadCustomSchemeErrorPage(WebView view) {
1009             final String errorPage = makeCustomSchemeErrorPage();
1010             view.loadDataWithBaseURL(INTERNAL_ASSETS, errorPage, "text/HTML", "UTF-8", null);
1011         }
1012 
showSslAlertDialog(SslErrorHandler handler, SslError error, String title)1013         private void showSslAlertDialog(SslErrorHandler handler, SslError error, String title) {
1014             final LayoutInflater factory = LayoutInflater.from(CaptivePortalLoginActivity.this);
1015             final View sslWarningView = factory.inflate(R.layout.ssl_warning, null);
1016 
1017             // Set Security certificate
1018             setViewSecurityCertificate(sslWarningView.findViewById(R.id.certificate_layout), error);
1019             ((TextView) sslWarningView.findViewById(R.id.ssl_error_type))
1020                     .setText(sslErrorName(error));
1021             ((TextView) sslWarningView.findViewById(R.id.title)).setText(mSslErrorTitle);
1022             ((TextView) sslWarningView.findViewById(R.id.address)).setText(error.getUrl());
1023 
1024             AlertDialog sslAlertDialog = new AlertDialog.Builder(CaptivePortalLoginActivity.this)
1025                     .setTitle(R.string.ssl_security_warning_title)
1026                     .setView(sslWarningView)
1027                     .setPositiveButton(R.string.ok, (DialogInterface dialog, int whichButton) -> {
1028                         // handler.cancel is called via OnCancelListener.
1029                         dialog.cancel();
1030                     })
1031                     .setOnCancelListener((DialogInterface dialogInterface) -> handler.cancel())
1032                     .create();
1033             sslAlertDialog.show();
1034         }
1035 
setViewSecurityCertificate(LinearLayout certificateLayout, SslError error)1036         private void setViewSecurityCertificate(LinearLayout certificateLayout, SslError error) {
1037             ((TextView) certificateLayout.findViewById(R.id.ssl_error_msg))
1038                     .setText(sslErrorMessage(error));
1039             SslCertificate cert = error.getCertificate();
1040             // TODO: call the method directly once inflateCertificateView is @SystemApi
1041             try {
1042                 final View certificateView = (View) SslCertificate.class.getMethod(
1043                         "inflateCertificateView", Context.class)
1044                         .invoke(cert, CaptivePortalLoginActivity.this);
1045                 certificateLayout.addView(certificateView);
1046             } catch (ReflectiveOperationException | SecurityException e) {
1047                 Log.e(TAG, "Could not create certificate view", e);
1048             }
1049         }
1050     }
1051 
1052     private class MyWebChromeClient extends WebChromeClient {
1053         @Override
onProgressChanged(WebView view, int newProgress)1054         public void onProgressChanged(WebView view, int newProgress) {
1055             getProgressBar().setProgress(newProgress);
1056         }
1057     }
1058 
1059     private class PortalDownloadListener implements DownloadListener {
1060         @Override
onDownloadStart(String url, String userAgent, String contentDisposition, String mimetype, long contentLength)1061         public void onDownloadStart(String url, String userAgent, String contentDisposition,
1062                 String mimetype, long contentLength) {
1063             final String normalizedType = Intent.normalizeMimeType(mimetype);
1064             // TODO: Need to sanitize the file name.
1065             final String displayName = URLUtil.guessFileName(
1066                     url, contentDisposition, normalizedType);
1067 
1068             String guessedMimetype = normalizedType;
1069             if (TextUtils.isEmpty(guessedMimetype)) {
1070                 guessedMimetype = URLConnection.guessContentTypeFromName(displayName);
1071             }
1072             if (TextUtils.isEmpty(guessedMimetype)) {
1073                 guessedMimetype = MediaStore.Downloads.CONTENT_TYPE;
1074             }
1075 
1076             Log.d(TAG, String.format("Starting download for %s, type %s with display name %s",
1077                     url, guessedMimetype, displayName));
1078 
1079             final int requestId;
1080             // WebView should call onDownloadStart from the UI thread, but to be extra-safe as
1081             // that is not documented behavior, access the download requests array with a lock.
1082             synchronized (mDownloadRequests) {
1083                 requestId = mNextDownloadRequestId++;
1084                 // Only bind the DownloadService for the first download. The request is put into
1085                 // array later, so size == 0 with null mDownloadService means it's the first item.
1086                 if (mDownloadService == null && mDownloadRequests.size() == 0) {
1087                     final Intent serviceIntent =
1088                             new Intent(CaptivePortalLoginActivity.this, DownloadService.class);
1089                     // To allow downloads to continue while the activity is closed, start service
1090                     // with a no-op intent, to make sure the service still gets put into started
1091                     // state.
1092                     startService(new Intent(getApplicationContext(), DownloadService.class));
1093                     bindService(serviceIntent, mDownloadServiceConn, Context.BIND_AUTO_CREATE);
1094                 }
1095             }
1096             // Skip file picker for directly open MIME type, such as wifi Passpoint configuration
1097             // files. Fallback to generic design if the download process can not start successfully.
1098             if (isDirectlyOpenType(guessedMimetype)) {
1099                 try {
1100                     startDirectlyOpenDownload(url, displayName, guessedMimetype, requestId);
1101                     return;
1102                 } catch (IOException | ActivityNotFoundException e) {
1103                     // Fallthrough to show the file picker
1104                     Log.d(TAG, "Unable to do directly open on the file", e);
1105                 }
1106             }
1107 
1108             synchronized (mDownloadRequests) {
1109                 // outFile will be assigned after file is created.
1110                 mDownloadRequests.put(requestId, new DownloadRequest(url, displayName,
1111                         guessedMimetype, null /* outFile */));
1112             }
1113 
1114             final Intent createFileIntent = DownloadService.makeCreateFileIntent(
1115                     guessedMimetype, displayName);
1116             try {
1117                 startActivityForResult(createFileIntent, requestId);
1118             } catch (ActivityNotFoundException e) {
1119                 // This could happen in theory if the device has no stock document provider (which
1120                 // Android normally requires), or if the user disabled all of them, but
1121                 // should be rare; the download cannot be started as no writeable file can be
1122                 // created.
1123                 Log.e(TAG, "No document provider found to create download file", e);
1124             }
1125         }
1126 
startDirectlyOpenDownload(String url, String filename, String mimeType, int requestId)1127         private void startDirectlyOpenDownload(String url, String filename, String mimeType,
1128                 int requestId) throws ActivityNotFoundException, IOException {
1129             ensureRunningOnMainThread();
1130             // Reject another directly open task if there is one task in progress. Using
1131             // mDirectlyOpenId here is ok because mDirectlyOpenId will not be updated to
1132             // non-NO_DIRECTLY_OPEN_TASK_ID until the new task is started.
1133             if (mDirectlyOpenId != NO_DIRECTLY_OPEN_TASK_ID) {
1134                 Log.d(TAG, "Existing directly open task is in progress. Ignore this.");
1135                 return;
1136             }
1137 
1138             final File downloadPath = new File(getFilesDir(), FILE_PROVIDER_DOWNLOAD_PATH);
1139             downloadPath.mkdirs();
1140             final File file = new File(downloadPath.getPath(), filename);
1141 
1142             final Uri uri = FileProvider.getUriForFile(
1143                     CaptivePortalLoginActivity.this, getFileProviderAuthority(), file);
1144 
1145             // Test if there is possible activity to handle this directly open file.
1146             final Intent testIntent = makeDirectlyOpenIntent(uri, mimeType);
1147             if (getPackageManager().resolveActivity(testIntent, 0 /* flag */) == null) {
1148                 // No available activity is able to handle this.
1149                 throw new ActivityNotFoundException("No available activity is able to handle "
1150                         + mimeType + " mime type file");
1151             }
1152 
1153             file.createNewFile();
1154             synchronized (mDownloadRequests) {
1155                 mDownloadRequests.put(requestId, new DownloadRequest(url, filename, mimeType, uri));
1156             }
1157 
1158             maybeStartPendingDownloads();
1159         }
1160     }
1161 
1162     /**
1163      * Get the {@link androidx.core.content.FileProvider} authority for storing downloaded files.
1164      *
1165      * Useful for tests to override so they can use their own storage directories.
1166      */
1167     @VisibleForTesting
getFileProviderAuthority()1168     String getFileProviderAuthority() {
1169         return FILE_PROVIDER_AUTHORITY;
1170     }
1171 
getProgressBar()1172     private ProgressBar getProgressBar() {
1173         return findViewById(R.id.progress_bar);
1174     }
1175 
getWebview()1176     private WebView getWebview() {
1177         return findViewById(R.id.webview);
1178     }
1179 
getProgressLayout()1180     private FrameLayout getProgressLayout() {
1181         return findViewById(R.id.downloading_panel);
1182     }
1183 
getHeaderTitle()1184     private String getHeaderTitle() {
1185         NetworkCapabilities nc = mCm.getNetworkCapabilities(mNetwork);
1186         final CharSequence networkName = getNetworkName(nc);
1187         if (TextUtils.isEmpty(networkName)
1188                 || nc == null || !nc.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
1189             return getString(R.string.action_bar_label);
1190         }
1191         return getString(R.string.action_bar_title, networkName);
1192     }
1193 
getNetworkName(NetworkCapabilities nc)1194     private CharSequence getNetworkName(NetworkCapabilities nc) {
1195         // Use the venue friendly name if available
1196         if (!TextUtils.isEmpty(mVenueFriendlyName)) {
1197             return mVenueFriendlyName;
1198         }
1199 
1200         // SSID is only available in NetworkCapabilities from R
1201         if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
1202             if (mWifiManager == null) {
1203                 return null;
1204             }
1205             final WifiInfo wifiInfo = getWifiConnectionInfo();
1206             return removeDoubleQuotes(wifiInfo.getSSID());
1207         }
1208 
1209         if (nc == null) {
1210             return null;
1211         }
1212         return removeDoubleQuotes(nc.getSsid());
1213     }
1214 
1215     @VisibleForTesting
getWifiConnectionInfo()1216     WifiInfo getWifiConnectionInfo() {
1217         return mWifiManager.getConnectionInfo();
1218     }
1219 
removeDoubleQuotes(String string)1220     private static String removeDoubleQuotes(String string) {
1221         if (string == null) return null;
1222         final int length = string.length();
1223         if ((length > 1) && (string.charAt(0) == '"') && (string.charAt(length - 1) == '"')) {
1224             return string.substring(1, length - 1);
1225         }
1226         return string;
1227     }
1228 
getHeaderSubtitle(URL url)1229     private String getHeaderSubtitle(URL url) {
1230         String host = host(url);
1231         final String https = "https";
1232         if (https.equals(url.getProtocol())) {
1233             return https + "://" + host;
1234         }
1235         return host;
1236     }
1237 
1238     private static final SparseArray<String> SSL_ERRORS = new SparseArray<>();
1239     static {
SSL_ERRORS.put(SslError.SSL_NOTYETVALID, "SSL_NOTYETVALID")1240         SSL_ERRORS.put(SslError.SSL_NOTYETVALID,  "SSL_NOTYETVALID");
SSL_ERRORS.put(SslError.SSL_EXPIRED, "SSL_EXPIRED")1241         SSL_ERRORS.put(SslError.SSL_EXPIRED,      "SSL_EXPIRED");
SSL_ERRORS.put(SslError.SSL_IDMISMATCH, "SSL_IDMISMATCH")1242         SSL_ERRORS.put(SslError.SSL_IDMISMATCH,   "SSL_IDMISMATCH");
SSL_ERRORS.put(SslError.SSL_UNTRUSTED, "SSL_UNTRUSTED")1243         SSL_ERRORS.put(SslError.SSL_UNTRUSTED,    "SSL_UNTRUSTED");
SSL_ERRORS.put(SslError.SSL_DATE_INVALID, "SSL_DATE_INVALID")1244         SSL_ERRORS.put(SslError.SSL_DATE_INVALID, "SSL_DATE_INVALID");
SSL_ERRORS.put(SslError.SSL_INVALID, "SSL_INVALID")1245         SSL_ERRORS.put(SslError.SSL_INVALID,      "SSL_INVALID");
1246     }
1247 
sslErrorName(SslError error)1248     private static String sslErrorName(SslError error) {
1249         return SSL_ERRORS.get(error.getPrimaryError(), "UNKNOWN");
1250     }
1251 
1252     private static final SparseArray<Integer> SSL_ERROR_MSGS = new SparseArray<>();
1253     static {
SSL_ERROR_MSGS.put(SslError.SSL_NOTYETVALID, R.string.ssl_error_not_yet_valid)1254         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)1255         SSL_ERROR_MSGS.put(SslError.SSL_EXPIRED,      R.string.ssl_error_expired);
SSL_ERROR_MSGS.put(SslError.SSL_IDMISMATCH, R.string.ssl_error_mismatch)1256         SSL_ERROR_MSGS.put(SslError.SSL_IDMISMATCH,   R.string.ssl_error_mismatch);
SSL_ERROR_MSGS.put(SslError.SSL_UNTRUSTED, R.string.ssl_error_untrusted)1257         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)1258         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)1259         SSL_ERROR_MSGS.put(SslError.SSL_INVALID,      R.string.ssl_error_invalid);
1260     }
1261 
sslErrorMessage(SslError error)1262     private static Integer sslErrorMessage(SslError error) {
1263         return SSL_ERROR_MSGS.get(error.getPrimaryError(), R.string.ssl_error_unknown);
1264     }
1265 
getVenueFriendlyName()1266     private CharSequence getVenueFriendlyName() {
1267         if (!isAtLeastR()) {
1268             return null;
1269         }
1270         final LinkProperties linkProperties = mCm.getLinkProperties(mNetwork);
1271         if (linkProperties == null) {
1272             return null;
1273         }
1274         if (linkProperties.getCaptivePortalData() == null) {
1275             return null;
1276         }
1277         final CaptivePortalData captivePortalData = linkProperties.getCaptivePortalData();
1278 
1279         if (captivePortalData == null) {
1280             return null;
1281         }
1282 
1283         // TODO: Use CaptivePortalData#getVenueFriendlyName when building with S
1284         // Use reflection for now
1285         final Class captivePortalDataClass = captivePortalData.getClass();
1286         try {
1287             final Method getVenueFriendlyNameMethod = captivePortalDataClass.getDeclaredMethod(
1288                     "getVenueFriendlyName");
1289             return (CharSequence) getVenueFriendlyNameMethod.invoke(captivePortalData);
1290         } catch (Exception e) {
1291             // Do nothing
1292         }
1293         return null;
1294     }
1295 }
1296