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