• 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.CaptivePortalLoginFlags.CAPTIVE_PORTAL_CUSTOM_TABS;
23 import static com.android.captiveportallogin.CaptivePortalLoginFlags.USE_ANY_CUSTOM_TAB_PROVIDER;
24 import static com.android.captiveportallogin.DownloadService.isDirectlyOpenType;
25 
26 import android.app.Activity;
27 import android.app.AlertDialog;
28 import android.app.Application;
29 import android.app.admin.DevicePolicyManager;
30 import android.content.ActivityNotFoundException;
31 import android.content.ComponentName;
32 import android.content.Context;
33 import android.content.DialogInterface;
34 import android.content.Intent;
35 import android.content.ServiceConnection;
36 import android.content.pm.PackageManager;
37 import android.content.pm.PackageManager.NameNotFoundException;
38 import android.content.pm.ResolveInfo;
39 import android.graphics.Bitmap;
40 import android.graphics.Insets;
41 import android.graphics.Rect;
42 import android.net.CaptivePortal;
43 import android.net.CaptivePortalData;
44 import android.net.ConnectivityManager;
45 import android.net.ConnectivityManager.NetworkCallback;
46 import android.net.LinkProperties;
47 import android.net.Network;
48 import android.net.NetworkCapabilities;
49 import android.net.NetworkRequest;
50 import android.net.Proxy;
51 import android.net.Uri;
52 import android.net.captiveportal.CaptivePortalProbeSpec;
53 import android.net.http.SslCertificate;
54 import android.net.http.SslError;
55 import android.net.wifi.WifiInfo;
56 import android.net.wifi.WifiManager;
57 import android.os.Build;
58 import android.os.Bundle;
59 import android.os.IBinder;
60 import android.os.Looper;
61 import android.os.OutcomeReceiver;
62 import android.os.ServiceSpecificException;
63 import android.os.SystemProperties;
64 import android.provider.DeviceConfig;
65 import android.provider.DocumentsContract;
66 import android.provider.MediaStore;
67 import android.system.OsConstants;
68 import android.text.TextUtils;
69 import android.util.ArrayMap;
70 import android.util.ArraySet;
71 import android.util.Log;
72 import android.util.SparseArray;
73 import android.util.TypedValue;
74 import android.view.LayoutInflater;
75 import android.view.Menu;
76 import android.view.MenuItem;
77 import android.view.View;
78 import android.view.ViewGroup;
79 import android.view.WindowInsets;
80 import android.webkit.CookieManager;
81 import android.webkit.DownloadListener;
82 import android.webkit.SslErrorHandler;
83 import android.webkit.URLUtil;
84 import android.webkit.WebChromeClient;
85 import android.webkit.WebResourceRequest;
86 import android.webkit.WebResourceResponse;
87 import android.webkit.WebSettings;
88 import android.webkit.WebView;
89 import android.webkit.WebViewClient;
90 import android.widget.FrameLayout;
91 import android.widget.LinearLayout;
92 import android.widget.ProgressBar;
93 import android.widget.TextView;
94 import android.widget.Toast;
95 
96 import androidx.annotation.GuardedBy;
97 import androidx.annotation.NonNull;
98 import androidx.annotation.Nullable;
99 import androidx.annotation.RequiresApi;
100 import androidx.annotation.StringRes;
101 import androidx.annotation.VisibleForTesting;
102 import androidx.browser.customtabs.CustomTabsCallback;
103 import androidx.browser.customtabs.CustomTabsClient;
104 import androidx.browser.customtabs.CustomTabsIntent;
105 import androidx.browser.customtabs.CustomTabsServiceConnection;
106 import androidx.browser.customtabs.CustomTabsSession;
107 import androidx.core.content.FileProvider;
108 import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
109 
110 import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
111 import com.android.modules.utils.build.SdkLevel;
112 import com.android.net.module.util.DeviceConfigUtils;
113 
114 import java.io.File;
115 import java.io.FileNotFoundException;
116 import java.io.IOException;
117 import java.lang.reflect.Field;
118 import java.lang.reflect.Method;
119 import java.net.MalformedURLException;
120 import java.net.URL;
121 import java.net.URLConnection;
122 import java.nio.file.Files;
123 import java.nio.file.Path;
124 import java.nio.file.Paths;
125 import java.util.ArrayList;
126 import java.util.Arrays;
127 import java.util.List;
128 import java.util.Objects;
129 import java.util.Random;
130 import java.util.concurrent.Executor;
131 import java.util.concurrent.atomic.AtomicBoolean;
132 
133 public class CaptivePortalLoginActivity extends Activity {
134     private static final String TAG = CaptivePortalLoginActivity.class.getSimpleName();
135     private static final boolean DBG = true;
136     private static final boolean VDBG = false;
137 
138     private static final int SOCKET_TIMEOUT_MS = 10000;
139     public static final String HTTP_LOCATION_HEADER_NAME = "Location";
140     private static final String DEFAULT_CAPTIVE_PORTAL_HTTP_URL =
141             "http://connectivitycheck.gstatic.com/generate_204";
142     // This should match the FileProvider authority specified in the app manifest.
143     private static final String FILE_PROVIDER_AUTHORITY =
144             "com.android.captiveportallogin.fileprovider";
145     // This should match the path name in the FileProvider paths XML.
146     @VisibleForTesting
147     static final String FILE_PROVIDER_DOWNLOAD_PATH = "downloads";
148     private static final int NO_DIRECTLY_OPEN_TASK_ID = -1;
149     private enum Result {
150         DISMISSED(MetricsEvent.ACTION_CAPTIVE_PORTAL_LOGIN_RESULT_DISMISSED),
151         UNWANTED(MetricsEvent.ACTION_CAPTIVE_PORTAL_LOGIN_RESULT_UNWANTED),
152         WANTED_AS_IS(MetricsEvent.ACTION_CAPTIVE_PORTAL_LOGIN_RESULT_WANTED_AS_IS);
153 
154         final int metricsEvent;
Result(int metricsEvent)155         Result(int metricsEvent) { this.metricsEvent = metricsEvent; }
156     };
157 
158     private URL mUrl;
159     private CaptivePortalProbeSpec mProbeSpec;
160     private String mUserAgent;
161     private Network mNetwork;
162     private CharSequence mVenueFriendlyName = null;
163     @VisibleForTesting
164     protected CaptivePortal mCaptivePortal;
165     private NetworkCallback mNetworkCallback;
166     private ConnectivityManager mCm;
167     private DevicePolicyManager mDpm;
168     private WifiManager mWifiManager;
169     private boolean mLaunchBrowser = false;
170     private MyWebViewClient mWebViewClient;
171     private SwipeRefreshLayout mSwipeRefreshLayout;
172     // This member is just used in the UI thread model(e.g. onCreate and onDestroy), so non-final
173     // should be fine.
174     private boolean mCaptivePortalCustomTabsEnabled;
175     // Ensures that done() happens once exactly, handling concurrent callers with atomic operations.
176     private final AtomicBoolean isDone = new AtomicBoolean(false);
177     // Must only be touched on the UI thread. This must be initialized to false for thread
178     // visibility reasons (if initialized to true, the UI thread may still see false).
179     private boolean mIsResumed = false;
180 
181     // Persistence across configuration changes, e.g. when the device is rotated, the
182     // window is resized in multi-window mode, or a hardware keyboard is attached.
183     // When this happens the app needs to know not to create a new custom tab, or it will
184     // have multiple tabs open on top of each other.
185     // This must only be touched on the main thread of the app.
186     private static final class PersistentState {
187         CaptivePortalCustomTabsServiceConnection mServiceConnection = null;
188         CaptivePortalCustomTabsCallback mCallback = null;
copyFrom(@onNull PersistentState other)189         public void copyFrom(@NonNull PersistentState other) {
190             mServiceConnection = other.mServiceConnection;
191             mCallback = other.mCallback;
192         }
193     }
194     // Must only be touched on the UI thread
195     private final PersistentState mPersistentState = new PersistentState();
196 
197     private static final class CaptivePortalCustomTabsCallback extends CustomTabsCallback {
198         @NonNull private CaptivePortalLoginActivity mParent;
199 
CaptivePortalCustomTabsCallback(@onNull final CaptivePortalLoginActivity parent)200         CaptivePortalCustomTabsCallback(@NonNull final CaptivePortalLoginActivity parent) {
201             mParent = parent;
202         }
203 
reparent(@onNull final CaptivePortalLoginActivity newParent)204         public void reparent(@NonNull final CaptivePortalLoginActivity newParent) {
205             mParent = newParent;
206         }
207 
208         @Override
onNavigationEvent(int navigationEvent, @Nullable Bundle extras)209         public void onNavigationEvent(int navigationEvent, @Nullable Bundle extras) {
210             if (navigationEvent == NAVIGATION_STARTED) {
211                 mParent.mCaptivePortal.reevaluateNetwork();
212             }
213             if (navigationEvent == TAB_HIDDEN) {
214                 // Run on UI thread to make sure mIsResumed is correctly visible.
215                 mParent.runOnUiThread(() -> {
216                     // The tab is hidden when the browser's activity is hidden : screen off,
217                     // home button, or press the close button on the tab. In the last case,
218                     // close the app. The activity behind the tab is only resumed in that case.
219                     if (mParent.mIsResumed) mParent.done(Result.DISMISSED);
220                 });
221             }
222         }
223     }
224 
225     private static final class CaptivePortalCustomTabsServiceConnection extends
226             CustomTabsServiceConnection {
227         @NonNull private CaptivePortalLoginActivity mParent;
228 
CaptivePortalCustomTabsServiceConnection( @onNull final CaptivePortalLoginActivity parent)229         CaptivePortalCustomTabsServiceConnection(
230                 @NonNull final CaptivePortalLoginActivity parent) {
231             mParent = parent;
232         }
233 
reparent(@onNull final CaptivePortalLoginActivity newParent)234         public void reparent(@NonNull final CaptivePortalLoginActivity newParent) {
235             mParent = newParent;
236         }
237 
238         @Override
onCustomTabsServiceConnected(@onNull ComponentName name, @NonNull CustomTabsClient client)239         public void onCustomTabsServiceConnected(@NonNull ComponentName name,
240                 @NonNull CustomTabsClient client) {
241             Log.d(TAG, "CustomTabs service connected");
242             final CustomTabsSession session = client.newSession(mParent.mPersistentState.mCallback);
243             // TODO : recompute available space when the app changes sizes
244             final View remainingSpaceView = mParent.findViewById(
245                     R.id.custom_tab_header_remaining_space);
246             int availableSpace = remainingSpaceView.getHeight();
247             if (availableSpace < 100) {
248                 // In some situations the layout pass is not done ? Not sure why yet but
249                 // as a stopgap use a fixed value
250                 final Rect windowSize =
251                         mParent.getWindowManager().getCurrentWindowMetrics().getBounds();
252                 final int top = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,
253                         96 /* dp */, mParent.getResources().getDisplayMetrics());
254                 availableSpace = (windowSize.bottom - windowSize.top) - top;
255             }
256             final int size = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,
257                     24 /* dp */, mParent.getResources().getDisplayMetrics());
258             final Bitmap emptyIcon = Bitmap.createBitmap(size /* width */, size /* height */,
259                     Bitmap.Config.ARGB_8888);
260             emptyIcon.setPixel(0, 0, 0);
261             // The application package name that will resolve to the CustomTabs intent
262             // has been set in {@Link CustomTabsIntent.Builder} constructor, unnecessary
263             // to call {@Link Intent#setPackage} to explicitly specify the package name
264             // again.
265             final CustomTabsIntent customTabsIntent = new CustomTabsIntent.Builder(session)
266                     .setNetwork(mParent.mNetwork)
267                     .setShareState(CustomTabsIntent.SHARE_STATE_OFF)
268                     // Do not show a title to avoid pages pretend they are part of the Android
269                     // system.
270                     .setShowTitle(false /* showTitle */)
271                     // Have the tab take up the available space under the header.
272                     .setInitialActivityHeightPx(availableSpace,
273                             CustomTabsIntent.ACTIVITY_HEIGHT_FIXED)
274                     // Don't show animations, because there is no content to animate from or to in
275                     // this activity. As such, set the res IDs to zero, which code for no animation.
276                     .setStartAnimations(mParent, 0, 0)
277                     .setExitAnimations(mParent, 0, 0)
278                     // Temporary workaround : use an empty icon for the close button. It doesn't
279                     // prevent interaction, but it least it doesn't LOOK like you can press it.
280                     .setCloseButtonIcon(emptyIcon)
281                     // External handlers will not work since they won't know on what network to
282                     // operate.
283                     .setSendToExternalDefaultHandlerEnabled(false)
284                     // No rounding on the corners so as to have the UI of the tab blend more
285                     // closely with the header contents.
286                     .setToolbarCornerRadiusDp(0)
287                     // Use the identity of the captive portal login app
288                     .setShareIdentityEnabled(true)
289                     // Don't hide the URL bar when scrolling down, to make sure the user is always
290                     // aware they are on the page from a captive portal.
291                     .setUrlBarHidingEnabled(false)
292                     .build();
293 
294             // Remove Referrer Header from HTTP probe packet by setting an empty Uri
295             // instance in EXTRA_REFERRER, make sure users using custom tabs have the
296             // same experience as the custom tabs browser.
297             final String emptyReferrer = "";
298             customTabsIntent.intent.putExtra(Intent.EXTRA_REFERRER, Uri.parse(emptyReferrer));
299             customTabsIntent.launchUrl(mParent, Uri.parse(mParent.mUrl.toString()));
300         }
301 
302         @Override
onServiceDisconnected(ComponentName componentName)303         public void onServiceDisconnected(ComponentName componentName) {
304             Log.d(TAG, "CustomTabs service disconnected");
305         }
306     }
307 
308     // When starting downloads a file is created via startActivityForResult(ACTION_CREATE_DOCUMENT).
309     // This array keeps the download request until the activity result is received. It is keyed by
310     // requestCode sent in startActivityForResult.
311     @GuardedBy("mDownloadRequests")
312     private final SparseArray<DownloadRequest> mDownloadRequests = new SparseArray<>();
313     @GuardedBy("mDownloadRequests")
314     private int mNextDownloadRequestId = 1;
315 
316     // mDownloadService and mDirectlyOpenId must be always updated from the main thread.
317     @VisibleForTesting
318     int mDirectlyOpenId = NO_DIRECTLY_OPEN_TASK_ID;
319     @Nullable
320     private DownloadService.DownloadServiceBinder mDownloadService = null;
321     private final ServiceConnection mDownloadServiceConn = new ServiceConnection() {
322         @Override
323         public void onServiceDisconnected(ComponentName name) {
324             Log.d(TAG, "Download service disconnected");
325             mDownloadService = null;
326             // Service binding is lost. The spinner for the directly open tasks is no longer
327             // needed.
328             setProgressSpinnerVisibility(View.GONE);
329         }
330 
331         @Override
332         public void onServiceConnected(ComponentName name, IBinder binder) {
333             Log.d(TAG, "Download service connected");
334             mDownloadService = (DownloadService.DownloadServiceBinder) binder;
335             mDownloadService.setProgressCallback(mProgressCallback);
336             maybeStartPendingDownloads();
337         }
338     };
339 
340     @Override
onPause()341     protected void onPause() {
342         mIsResumed = false;
343         super.onPause();
344     }
345 
346     @Override
onResume()347     protected void onResume() {
348         mIsResumed = true;
349         super.onResume();
350     }
351 
352     @VisibleForTesting
353     final DownloadService.ProgressCallback mProgressCallback =
354             new DownloadService.ProgressCallback() {
355         @Override
356         public void onDownloadComplete(Uri inputFile, String mimeType, int downloadId,
357                 boolean success) {
358             if (isDirectlyOpenType(mimeType) && success) {
359                 try {
360                     startActivity(makeDirectlyOpenIntent(inputFile, mimeType));
361                 } catch (ActivityNotFoundException e) {
362                     // Delete the directly open file if no activity could handle it. This is
363                     // verified before downloading, so it should only happen when the handling app
364                     // was uninstalled while downloading, which is vanishingly rare. Try to delete
365                     // it in case of the target activity being removed somehow.
366                     Log.wtf(TAG, "No activity could handle " + mimeType + " file.", e);
367                     runOnUiThread(() -> tryDeleteFile(inputFile));
368                 }
369             }
370 
371             verifyDownloadIdAndMaybeHideSpinner(downloadId);
372         }
373 
374         @Override
375         public void onDownloadAborted(int downloadId, int reason) {
376             if (reason == DownloadService.DOWNLOAD_ABORTED_REASON_FILE_TOO_LARGE) {
377                 runOnUiThread(() -> Toast.makeText(CaptivePortalLoginActivity.this,
378                         R.string.file_too_large_cancel_download, Toast.LENGTH_LONG).show());
379             }
380 
381             verifyDownloadIdAndMaybeHideSpinner(downloadId);
382         }
383 
384         private void verifyDownloadIdAndMaybeHideSpinner(int id) {
385             // Hide the spinner when the task completed signal for the target task is received.
386             //
387             // mDirectlyOpenId will not be updated until the existing directly open task is
388             // completed or the connection to the DownloadService is lost. If the id is updated to
389             // NO_DIRECTLY_OPEN_TASK_ID because of the loss of connection to DownloadService, the
390             // spinner should be already hidden. Receiving relevant callback is ignorable.
391             runOnUiThread(() -> {
392                 if (mDirectlyOpenId == id) setProgressSpinnerVisibility(View.GONE);
393             });
394         }
395     };
396 
397     @VisibleForTesting
isFeatureEnabled(final String name)398     boolean isFeatureEnabled(final String name) {
399         return DeviceConfigUtils.isCaptivePortalLoginFeatureEnabled(getApplicationContext(), name);
400     }
401 
402     @VisibleForTesting
getDeviceConfigPropertyBoolean(final String name, boolean defaultValue)403     boolean getDeviceConfigPropertyBoolean(final String name, boolean defaultValue) {
404         return DeviceConfigUtils.getDeviceConfigPropertyBoolean(
405                 DeviceConfig.NAMESPACE_CAPTIVEPORTALLOGIN, name, defaultValue);
406     }
407 
maybeStartPendingDownloads()408     private void maybeStartPendingDownloads() {
409         ensureRunningOnMainThread();
410 
411         if (mDownloadService == null) return;
412         synchronized (mDownloadRequests) {
413             for (int i = 0; i < mDownloadRequests.size(); i++) {
414                 final DownloadRequest req = mDownloadRequests.valueAt(i);
415                 if (req.mOutFile == null) continue;
416 
417                 final int dlId = mDownloadService.requestDownload(mNetwork, mUserAgent, req.mUrl,
418                         req.mFilename, req.mOutFile, getApplicationContext(), req.mMimeType);
419                 if (isDirectlyOpenType(req.mMimeType)) {
420                     mDirectlyOpenId = dlId;
421                     setProgressSpinnerVisibility(View.VISIBLE);
422                 }
423 
424                 mDownloadRequests.removeAt(i);
425                 i--;
426             }
427         }
428     }
429 
makeDirectlyOpenIntent(Uri inputFile, String mimeType)430     private Intent makeDirectlyOpenIntent(Uri inputFile, String mimeType) {
431         return new Intent(Intent.ACTION_VIEW)
432                 .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
433                         | Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
434                 .setDataAndType(inputFile, mimeType);
435     }
436 
tryDeleteFile(@onNull Uri file)437     private void tryDeleteFile(@NonNull Uri file) {
438         ensureRunningOnMainThread();
439         try {
440             DocumentsContract.deleteDocument(getContentResolver(), file);
441         } catch (FileNotFoundException e) {
442             // Nothing to delete
443             Log.wtf(TAG, file + " not found for deleting");
444         }
445     }
446 
447     private static final class DownloadRequest {
448         @NonNull final String mUrl;
449         @NonNull final String mFilename;
450         @NonNull final String mMimeType;
451         // mOutFile is null for requests where the device is currently asking the user to pick a
452         // place to put the file. When the user has picked the file name, the request will be
453         // replaced by a new one with the correct file name in onActivityResult.
454         @Nullable final Uri mOutFile;
DownloadRequest(@onNull String url, @NonNull String filename, @NonNull String mimeType, @Nullable Uri outFile)455         DownloadRequest(@NonNull String url, @NonNull String filename, @NonNull String mimeType,
456                 @Nullable Uri outFile) {
457             mUrl = url;
458             mFilename = filename;
459             mMimeType = mimeType;
460             mOutFile = outFile;
461         }
462     }
463 
464     // Ideally there should be a setting to let the user decide whether they want to
465     // use custom tabs from a non-default browser for captive portals. Most users are
466     // expected not to want custom tabs from a non-default browser : there
467     // is a good chance they don't trust the company making a non-default browser that
468     // is installed by default on their phone, or even if they trust it they may just
469     // dislike it. Users tend to be passionate about their browser preference.
470     // Still there is a use case for this, like playing DRM-protected content. Absent
471     // trust and like issues, a non-default browser is still probably a more competent
472     // implementation than the webview, and while it probably doesn't have the user's
473     // credentials or personal info, it is likely better at handling SSL errors, non-
474     // default schemes, login status and the like.
475     // Until there is such a setting, the captive portal login app should default to
476     // only use the default browser, and use the webview if the default browser does
477     // not support custom tabs with multi-networking.
478     // However, temporarily to help with tests, using any browser with the available
479     // capabilities is useful. As such, only do this if the hidden device config
480     // USE_ANY_CUSTOM_TAB_PROVIDER is true.
481     @Nullable
getAnyCustomTabsProviderPackage()482     String getAnyCustomTabsProviderPackage() {
483         // Get all apps that can handle VIEW intents and Custom Tab service connections.
484         final Intent activityIntent = new Intent(Intent.ACTION_VIEW, Uri.parse("http://"));
485         final List<String> packages = new ArrayList<>();
486         for (final ResolveInfo resolveInfo : getPackageManager()
487                 .queryIntentActivities(activityIntent, PackageManager.MATCH_ALL)) {
488             if (null == resolveInfo || null == resolveInfo.activityInfo) continue;
489             if (isMultiNetworkingSupportedByProvider(resolveInfo.activityInfo.packageName)) {
490                 packages.add(resolveInfo.activityInfo.packageName);
491             }
492         }
493         if (packages.isEmpty()) return null;
494         final List<String> priorities = Arrays.asList(".dev", ".canary", ".beta");
495         for (String priority : priorities) {
496             for (String packageName : packages) {
497                 if (packageName.endsWith(priority)) {
498                     return packageName;
499                 }
500             }
501         }
502         return packages.get(0);
503     }
504 
505     @VisibleForTesting
506     @Nullable
getDefaultCustomTabsProviderPackage()507     String getDefaultCustomTabsProviderPackage() {
508         return CustomTabsClient.getPackageName(getApplicationContext(), null /* packages */);
509     }
510 
511     @VisibleForTesting
getPackageUid(@onNull final String customTabsProviderPackageName)512     int getPackageUid(@NonNull final String customTabsProviderPackageName)
513             throws NameNotFoundException {
514         return getPackageManager().getPackageUid(customTabsProviderPackageName, 0);
515     }
516 
517     @VisibleForTesting
isMultiNetworkingSupportedByProvider(@onNull final String defaultPackageName)518     boolean isMultiNetworkingSupportedByProvider(@NonNull final String defaultPackageName) {
519         return CustomTabsClient.isSetNetworkSupported(getApplicationContext(), defaultPackageName);
520     }
521 
522     @VisibleForTesting
getContextForCustomTabsBinding()523     Context getContextForCustomTabsBinding() {
524         return getApplicationContext();
525     }
526 
applyWindowInsets(final int resourceId)527     private void applyWindowInsets(final int resourceId) {
528         if (!SdkLevel.isAtLeastV()) return;
529         final View view = findViewById(resourceId);
530         view.setOnApplyWindowInsetsListener((v, windowInsets) -> {
531             final Insets insets = windowInsets.getInsets(WindowInsets.Type.systemBars());
532             v.setPadding(0 /* left */, insets.top /* top */, 0 /* right */,
533                     0 /* bottom */);
534             return windowInsets.inset(0, insets.top, 0, 0);
535         });
536     }
537 
initializeCustomTabHeader()538     private void initializeCustomTabHeader() {
539         setContentView(R.layout.activity_custom_tab_header);
540         // No action bar as this activity implements its own UI instead, so it can display more
541         // useful information, e.g. about VPN or private DNS handling.
542         getActionBar().hide();
543         final TextView headerTitle = findViewById(R.id.custom_tab_header_title);
544         headerTitle.setText(getHeaderTitle());
545         applyWindowInsets(R.id.custom_tab_header_top_bar);
546     }
547 
initializeWebView()548     private void initializeWebView() {
549         // Also initializes proxy system properties.
550         mCm.bindProcessToNetwork(mNetwork);
551 
552         // Proxy system properties must be initialized before setContentView is called
553         // because setContentView initializes the WebView logic which in turn reads the
554         // system properties.
555         setContentView(R.layout.activity_captive_portal_login);
556 
557         getActionBar().setDisplayShowHomeEnabled(false);
558         getActionBar().setElevation(0); // remove shadow
559         getActionBar().setTitle(getHeaderTitle());
560         getActionBar().setSubtitle("");
561 
562         applyWindowInsets(R.id.container);
563 
564         final WebView webview = getWebview();
565         webview.clearCache(true);
566         CookieManager.getInstance().setAcceptThirdPartyCookies(webview, true);
567         WebSettings webSettings = webview.getSettings();
568         webSettings.setJavaScriptEnabled(true);
569         webSettings.setMixedContentMode(WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE);
570         webSettings.setUseWideViewPort(true);
571         webSettings.setLoadWithOverviewMode(true);
572         webSettings.setSupportZoom(true);
573         webSettings.setBuiltInZoomControls(true);
574         webSettings.setDisplayZoomControls(false);
575         webSettings.setDomStorageEnabled(true);
576         mWebViewClient = new MyWebViewClient();
577         webview.setWebViewClient(mWebViewClient);
578         webview.setWebChromeClient(new MyWebChromeClient());
579         webview.setDownloadListener(new PortalDownloadListener());
580         // Start initial page load so WebView finishes loading proxy settings.
581         // Actual load of mUrl is initiated by MyWebViewClient.
582         webview.loadData("", "text/html", null);
583 
584         mSwipeRefreshLayout = findViewById(R.id.swipe_refresh);
585         mSwipeRefreshLayout.setOnRefreshListener(() -> {
586             webview.reload();
587             mSwipeRefreshLayout.setRefreshing(true);
588         });
589     }
590 
bindCustomTabsService(@onNull final String customTabsProviderPackageName)591     private void bindCustomTabsService(@NonNull final String customTabsProviderPackageName) {
592         CustomTabsClient.bindCustomTabsService(getContextForCustomTabsBinding(),
593                 customTabsProviderPackageName, mPersistentState.mServiceConnection);
594     }
595 
596     @RequiresApi(Build.VERSION_CODES.S)
bypassVpnForCustomTabsProvider( @onNull final String customTabsProviderPackageName, @NonNull final OutcomeReceiver<Void, ServiceSpecificException> receiver)597     private boolean bypassVpnForCustomTabsProvider(
598             @NonNull final String customTabsProviderPackageName,
599             @NonNull final OutcomeReceiver<Void, ServiceSpecificException> receiver) {
600         final Class captivePortalClass = mCaptivePortal.getClass();
601         try {
602             final Method setDelegateUidMethod =
603                     captivePortalClass.getMethod("setDelegateUid", int.class, Executor.class,
604                             OutcomeReceiver.class);
605             setDelegateUidMethod.invoke(mCaptivePortal,
606                     getPackageUid(customTabsProviderPackageName),
607                     getMainExecutor(),
608                     receiver);
609             return true;
610         } catch (ReflectiveOperationException | IllegalArgumentException e) {
611             Log.e(TAG, "Reflection exception while setting delegate uid", e);
612             return false;
613         } catch (NameNotFoundException e) {
614             Log.e(TAG, "Could not find the UID for " + customTabsProviderPackageName, e);
615             return false;
616         }
617     }
618 
619     @Nullable
getCustomTabsProviderPackageIfEnabled()620     private String getCustomTabsProviderPackageIfEnabled() {
621         if (!mCaptivePortalCustomTabsEnabled) return null;
622 
623         // TODO: b/330670424 - check if privacy settings such as private DNS is bypassable,
624         // otherwise, fallback to WebView.
625         final LinkProperties lp = mCm.getLinkProperties(mNetwork);
626         if (lp == null || lp.getPrivateDnsServerName() != null) {
627             Log.i(TAG, "Do not use custom tabs if private DNS (strict mode) is enabled");
628             return null;
629         }
630 
631         final String defaultPackage = getDefaultCustomTabsProviderPackage();
632         if (null != defaultPackage && isMultiNetworkingSupportedByProvider(defaultPackage)) {
633             return defaultPackage;
634         }
635 
636         Log.i(TAG, "Default browser doesn't support custom tabs");
637 
638         // Intentionally no UX way to set this. It is useful for verifying the test-only feature
639         // with the early development version of browser.
640         final boolean useAnyCustomTabProvider =
641                 getDeviceConfigPropertyBoolean(USE_ANY_CUSTOM_TAB_PROVIDER,
642                         false /* defaultValue */);
643         if (!useAnyCustomTabProvider) return null;
644         return getAnyCustomTabsProviderPackage();
645     }
646 
647     @Override
onRetainNonConfigurationInstance()648     public Object onRetainNonConfigurationInstance() {
649         return mPersistentState;
650     }
651 
652     @Override
onCreate(@ullable Bundle savedInstanceState)653     protected void onCreate(@Nullable Bundle savedInstanceState) {
654         super.onCreate(savedInstanceState);
655         // Initialize the feature flag after CaptivePortalLoginActivity is created, otherwise, the
656         // context is still null and throw NPE when fetching the package manager from context.
657         mCaptivePortalCustomTabsEnabled = isFeatureEnabled(CAPTIVE_PORTAL_CUSTOM_TABS);
658         mCaptivePortal = getIntent().getParcelableExtra(ConnectivityManager.EXTRA_CAPTIVE_PORTAL);
659         final PersistentState lastState = (PersistentState) getLastNonConfigurationInstance();
660         if (null != lastState) {
661             mPersistentState.copyFrom(lastState);
662         }
663         // Null CaptivePortal is unexpected. The following flow will need to access mCaptivePortal
664         // to communicate with system. Thus, finish the activity.
665         if (mCaptivePortal == null) {
666             Log.e(TAG, "Unexpected null CaptivePortal");
667             finish();
668             return;
669         }
670         mCm = getSystemService(ConnectivityManager.class);
671         mDpm = getSystemService(DevicePolicyManager.class);
672         mWifiManager = getSystemService(WifiManager.class);
673         mNetwork = getIntent().getParcelableExtra(ConnectivityManager.EXTRA_NETWORK);
674         mNetwork = mNetwork.getPrivateDnsBypassingCopy();
675         mVenueFriendlyName = getVenueFriendlyName();
676         mUserAgent =
677                 getIntent().getStringExtra(ConnectivityManager.EXTRA_CAPTIVE_PORTAL_USER_AGENT);
678         mUrl = getUrl();
679         if (mUrl == null) {
680             // getUrl() failed to parse the url provided in the intent: bail out in a way that
681             // at least provides network access.
682             done(Result.WANTED_AS_IS);
683             return;
684         }
685         if (DBG) {
686             Log.d(TAG, String.format("onCreate for %s", mUrl));
687         }
688 
689         final String spec = getIntent().getStringExtra(EXTRA_CAPTIVE_PORTAL_PROBE_SPEC);
690         try {
691             mProbeSpec = CaptivePortalProbeSpec.parseSpecOrNull(spec);
692         } catch (Exception e) {
693             // Make extra sure that invalid configurations do not cause crashes
694             mProbeSpec = null;
695         }
696 
697         mNetworkCallback = new NetworkCallback() {
698             @Override
699             public void onLost(Network lostNetwork) {
700                 // If the network disappears while the app is up, exit.
701                 if (mNetwork.equals(lostNetwork)) done(Result.UNWANTED);
702             }
703 
704             @Override
705             public void onCapabilitiesChanged(Network network, NetworkCapabilities nc) {
706                 handleCapabilitiesChanged(network, nc);
707             }
708         };
709         mCm.registerNetworkCallback(new NetworkRequest.Builder().build(), mNetworkCallback);
710 
711         // If the network has disappeared, exit.
712         final NetworkCapabilities networkCapabilities = mCm.getNetworkCapabilities(mNetwork);
713         if (networkCapabilities == null) {
714             finishAndRemoveTask();
715             return;
716         }
717 
718         maybeDeleteDirectlyOpenFile();
719 
720         final String customTabsProviderPackageName = getCustomTabsProviderPackageIfEnabled();
721         if (customTabsProviderPackageName == null || !SdkLevel.isAtLeastS()) {
722             initializeWebView();
723         } else {
724             initializeCustomTabHeader();
725             if (mPersistentState.mCallback != null) {
726                 mPersistentState.mCallback.reparent(this);
727             } else {
728                 mPersistentState.mCallback = new CaptivePortalCustomTabsCallback(this);
729             }
730 
731             if (mPersistentState.mServiceConnection != null) {
732                 mPersistentState.mServiceConnection.reparent(this);
733             } else {
734                 mPersistentState.mServiceConnection =
735                         new CaptivePortalCustomTabsServiceConnection(this);
736                 // TODO: Fall back to WebView iff VPN is enabled and the custom tabs provider is not
737                 // allowed to bypass VPN, e.g. an error or exception happens when calling the
738                 // {@link CaptivePortal#setDelegateUid} API. Otherwise, force launch the custom tabs
739                 // even if VPN cannot be bypassed.
740                 final boolean success = bypassVpnForCustomTabsProvider(
741                         customTabsProviderPackageName,
742                         new OutcomeReceiver<Void, ServiceSpecificException>() {
743                             // TODO: log the callback result metrics.
744                             @Override
745                             public void onResult(Void r) {
746                                 Log.d(TAG, "Set delegate uid for "
747                                         + customTabsProviderPackageName
748                                         + " to bypass VPN successfully");
749                                 bindCustomTabsService(customTabsProviderPackageName);
750                             }
751 
752                             @Override
753                             public void onError(ServiceSpecificException e) {
754                                 Log.e(TAG, "Fail to set delegate uid for "
755                                         + customTabsProviderPackageName + " to bypass VPN"
756                                         + ", error: " + OsConstants.errnoName(e.errorCode), e);
757                                 bindCustomTabsService(customTabsProviderPackageName);
758                             }
759                         });
760                 if (!success) { // caught an exception
761                     bindCustomTabsService(customTabsProviderPackageName);
762                 }
763             }
764         }
765     }
766 
maybeDeleteDirectlyOpenFile()767     private void maybeDeleteDirectlyOpenFile() {
768         // Try to remove the directly open files if exists.
769         final File downloadPath = new File(getFilesDir(), FILE_PROVIDER_DOWNLOAD_PATH);
770         try {
771             deleteRecursively(downloadPath);
772         } catch (IOException e) {
773             Log.d(TAG, "Exception while deleting temp download files", e);
774         }
775     }
776 
deleteRecursively(final File path)777     private static boolean deleteRecursively(final File path) throws IOException {
778         if (path.isDirectory()) {
779             final File[] files = path.listFiles();
780             if (files != null) {
781                 for (final File child : files) {
782                     deleteRecursively(child);
783                 }
784             }
785         }
786         final Path parsedPath = Paths.get(path.toURI());
787         Log.d(TAG, "Cleaning up " + parsedPath);
788         return Files.deleteIfExists(parsedPath);
789     }
790 
791     @VisibleForTesting
getWebViewClient()792     MyWebViewClient getWebViewClient() {
793         return mWebViewClient;
794     }
795 
796     @VisibleForTesting
handleCapabilitiesChanged(@onNull final Network network, @NonNull final NetworkCapabilities nc)797     void handleCapabilitiesChanged(@NonNull final Network network,
798             @NonNull final NetworkCapabilities nc) {
799         if (network.equals(mNetwork) && nc.hasCapability(NET_CAPABILITY_VALIDATED)) {
800             // Dismiss when login is no longer needed since network has validated, exit.
801             done(Result.DISMISSED);
802         }
803     }
804 
805     // Find WebView's proxy BroadcastReceiver and prompt it to read proxy system properties.
setWebViewProxy()806     private void setWebViewProxy() {
807         // TODO: migrate to androidx WebView proxy setting API as soon as it is finalized
808         try {
809             final Field loadedApkField = Application.class.getDeclaredField("mLoadedApk");
810             final Class<?> loadedApkClass = loadedApkField.getType();
811             final Object loadedApk = loadedApkField.get(getApplication());
812             Field receiversField = loadedApkClass.getDeclaredField("mReceivers");
813             receiversField.setAccessible(true);
814             ArrayMap receivers = (ArrayMap) receiversField.get(loadedApk);
815             for (Object receiverMap : receivers.values()) {
816                 for (Object rec : ((ArrayMap) receiverMap).keySet()) {
817                     Class clazz = rec.getClass();
818                     if (clazz.getName().contains("ProxyChangeListener")) {
819                         Method onReceiveMethod = clazz.getDeclaredMethod("onReceive", Context.class,
820                                 Intent.class);
821                         Intent intent = new Intent(Proxy.PROXY_CHANGE_ACTION);
822                         onReceiveMethod.invoke(rec, getApplicationContext(), intent);
823                         Log.v(TAG, "Prompting WebView proxy reload.");
824                     }
825                 }
826             }
827         } catch (Exception e) {
828             Log.e(TAG, "Exception while setting WebView proxy: " + e);
829         }
830     }
831 
done(Result result)832     private void done(Result result) {
833         if (isDone.getAndSet(true)) {
834             // isDone was already true: done() already called
835             return;
836         }
837         if (DBG) {
838             Log.d(TAG, String.format("Result %s for %s", result.name(), mUrl));
839         }
840         switch (result) {
841             case DISMISSED:
842                 mCaptivePortal.reportCaptivePortalDismissed();
843                 break;
844             case UNWANTED:
845                 mCaptivePortal.ignoreNetwork();
846                 break;
847             case WANTED_AS_IS:
848                 mCaptivePortal.useNetwork();
849                 break;
850         }
851         finishAndRemoveTask();
852     }
853 
854     @Override
onCreateOptionsMenu(Menu menu)855     public boolean onCreateOptionsMenu(Menu menu) {
856         getMenuInflater().inflate(R.menu.captive_portal_login, menu);
857         return true;
858     }
859 
860     @Override
onBackPressed()861     public void onBackPressed() {
862         final WebView myWebView = findViewById(R.id.webview);
863         // The web view is null if the app is using custom tabs
864         if (null != myWebView && myWebView.canGoBack() && mWebViewClient.allowBack()) {
865             myWebView.goBack();
866         } else {
867             super.onBackPressed();
868         }
869     }
870 
871     @Override
onOptionsItemSelected(MenuItem item)872     public boolean onOptionsItemSelected(MenuItem item) {
873         final Result result;
874         final String action;
875         final int id = item.getItemId();
876         // This can't be a switch case because resource will be declared as static only but not
877         // static final as of ADT 14 in a library project. See
878         // http://tools.android.com/tips/non-constant-fields.
879         if (id == R.id.action_use_network) {
880             result = Result.WANTED_AS_IS;
881             action = "USE_NETWORK";
882         } else if (id == R.id.action_do_not_use_network) {
883             result = Result.UNWANTED;
884             action = "DO_NOT_USE_NETWORK";
885         } else {
886             return super.onOptionsItemSelected(item);
887         }
888         if (DBG) {
889             Log.d(TAG, String.format("onOptionsItemSelect %s for %s", action, mUrl));
890         }
891         done(result);
892         return true;
893     }
894 
895     @Override
onStop()896     public void onStop() {
897         super.onStop();
898         cancelPendingTask();
899     }
900 
901     // This must be always called from main thread.
setProgressSpinnerVisibility(int visibility)902     private void setProgressSpinnerVisibility(int visibility) {
903         ensureRunningOnMainThread();
904 
905         // getProgressLayout should never return null here, because this method is only ever called
906         // when running in webview mode.
907         getProgressLayout().setVisibility(visibility);
908         if (visibility != View.VISIBLE) {
909             mDirectlyOpenId = NO_DIRECTLY_OPEN_TASK_ID;
910         }
911     }
912 
913     @VisibleForTesting
cancelPendingTask()914     void cancelPendingTask() {
915         ensureRunningOnMainThread();
916         if (mDirectlyOpenId != NO_DIRECTLY_OPEN_TASK_ID) {
917             Toast.makeText(this, R.string.cancel_pending_downloads, Toast.LENGTH_SHORT).show();
918             // Remove the pending task for downloading the directly open file.
919             mDownloadService.cancelTask(mDirectlyOpenId);
920         }
921     }
922 
ensureRunningOnMainThread()923     private void ensureRunningOnMainThread() {
924         if (Looper.getMainLooper().getThread() != Thread.currentThread()) {
925             throw new IllegalStateException(
926                     "Not running on main thread: " + Thread.currentThread().getName());
927         }
928     }
929 
930     @Override
onDestroy()931     public void onDestroy() {
932         super.onDestroy();
933 
934         if (mDownloadService != null) {
935             unbindService(mDownloadServiceConn);
936         }
937 
938         // When changing configurations, the activity will be restarted immediately by the
939         // system. It will retain persistent state with onRetainNonConfigurationInstance,
940         // and therefore the connection must not be severed just yet.
941         if (null != mPersistentState.mServiceConnection && !isChangingConfigurations()) {
942             getContextForCustomTabsBinding().unbindService(mPersistentState.mServiceConnection);
943             mPersistentState.mServiceConnection = null;
944         }
945 
946         final WebView webview = (WebView) findViewById(R.id.webview);
947         if (webview != null) {
948             webview.stopLoading();
949             webview.setWebViewClient(null);
950             webview.setWebChromeClient(null);
951             // According to the doc of WebView#destroy(), webview should be removed from the view
952             // system before calling the WebView#destroy().
953             ((ViewGroup) webview.getParent()).removeView(webview);
954             webview.destroy();
955         }
956         if (mNetworkCallback != null) {
957             // mNetworkCallback is not null if mUrl is not null.
958             mCm.unregisterNetworkCallback(mNetworkCallback);
959         }
960         if (mLaunchBrowser) {
961             // Give time for this network to become default. After 500ms just proceed.
962             for (int i = 0; i < 5; i++) {
963                 // TODO: This misses when mNetwork underlies a VPN.
964                 if (mNetwork.equals(mCm.getActiveNetwork())) break;
965                 try {
966                     Thread.sleep(100);
967                 } catch (InterruptedException e) {
968                 }
969             }
970             final String url = mUrl.toString();
971             if (DBG) {
972                 Log.d(TAG, "starting activity with intent ACTION_VIEW for " + url);
973             }
974             startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url)));
975         }
976     }
977 
978     @Override
onActivityResult(int requestCode, int resultCode, Intent data)979     protected void onActivityResult(int requestCode, int resultCode, Intent data) {
980         if (resultCode != RESULT_OK || data == null) return;
981 
982         // Start download after receiving a created file to download to
983         final DownloadRequest pendingRequest;
984         synchronized (mDownloadRequests) {
985             pendingRequest = mDownloadRequests.get(requestCode);
986             if (pendingRequest == null) {
987                 Log.e(TAG, "No pending download for request " + requestCode);
988                 return;
989             }
990         }
991 
992         final Uri fileUri = data.getData();
993         if (fileUri == null) {
994             Log.e(TAG, "No file received from download file creation result");
995             return;
996         }
997 
998         synchronized (mDownloadRequests) {
999             // Replace the pending request with file uri in mDownloadRequests.
1000             mDownloadRequests.put(requestCode, new DownloadRequest(pendingRequest.mUrl,
1001                     pendingRequest.mFilename, pendingRequest.mMimeType, fileUri));
1002         }
1003         maybeStartPendingDownloads();
1004     }
1005 
getUrl()1006     private URL getUrl() {
1007         String url = getIntent().getStringExtra(ConnectivityManager.EXTRA_CAPTIVE_PORTAL_URL);
1008         if (url == null) { // TODO: Have a metric to know how often empty url happened.
1009             // ConnectivityManager#getCaptivePortalServerUrl is deprecated starting with Android R.
1010             if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {
1011                 url = DEFAULT_CAPTIVE_PORTAL_HTTP_URL;
1012             } else {
1013                 url = mCm.getCaptivePortalServerUrl();
1014             }
1015         }
1016         return makeURL(url);
1017     }
1018 
makeURL(String url)1019     private static URL makeURL(String url) {
1020         try {
1021             return new URL(url);
1022         } catch (MalformedURLException e) {
1023             Log.e(TAG, "Invalid URL " + url);
1024         }
1025         return null;
1026     }
1027 
host(URL url)1028     private static String host(URL url) {
1029         if (url == null) {
1030             return null;
1031         }
1032         return url.getHost();
1033     }
1034 
sanitizeURL(URL url)1035     private static String sanitizeURL(URL url) {
1036         // In non-Debug build, only show host to avoid leaking private info.
1037         return isDebuggable() ? Objects.toString(url) : host(url);
1038     }
1039 
isDebuggable()1040     private static boolean isDebuggable() {
1041         return SystemProperties.getInt("ro.debuggable", 0) == 1;
1042     }
1043 
isDismissed( int httpResponseCode, String locationHeader, CaptivePortalProbeSpec probeSpec)1044     private static boolean isDismissed(
1045             int httpResponseCode, String locationHeader, CaptivePortalProbeSpec probeSpec) {
1046         return (probeSpec != null)
1047                 ? probeSpec.getResult(httpResponseCode, locationHeader).isSuccessful()
1048                 : (httpResponseCode == 204);
1049     }
1050 
1051     @VisibleForTesting
hasVpnNetwork()1052     boolean hasVpnNetwork() {
1053         for (Network network : mCm.getAllNetworks()) {
1054             final NetworkCapabilities nc = mCm.getNetworkCapabilities(network);
1055             if (nc != null && nc.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
1056                 return true;
1057             }
1058         }
1059 
1060         return false;
1061     }
1062 
1063     @VisibleForTesting
isAlwaysOnVpnEnabled()1064     boolean isAlwaysOnVpnEnabled() {
1065         final ComponentName cn = new ComponentName(this, CaptivePortalLoginActivity.class);
1066         return mDpm.isAlwaysOnVpnLockdownEnabled(cn);
1067     }
1068 
1069     @VisibleForTesting
1070     class MyWebViewClient extends WebViewClient {
1071         private static final String INTERNAL_ASSETS = "file:///android_asset/";
1072 
1073         private final String mBrowserBailOutToken = Long.toString(new Random().nextLong());
1074         private final String mCertificateOutToken = Long.toString(new Random().nextLong());
1075         // How many Android device-independent-pixels per scaled-pixel
1076         // dp/sp = (px/sp) / (px/dp) = (1/sp) / (1/dp)
1077         private final float mDpPerSp = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 1,
1078                     getResources().getDisplayMetrics()) /
1079                     TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1,
1080                     getResources().getDisplayMetrics());
1081         private int mPagesLoaded;
1082         private final ArraySet<String> mMainFrameUrls = new ArraySet<>();
1083 
1084         // If we haven't finished cleaning up the history, don't allow going back.
allowBack()1085         public boolean allowBack() {
1086             return mPagesLoaded > 1;
1087         }
1088 
1089         private String mSslErrorTitle = null;
1090         private SslErrorHandler mSslErrorHandler = null;
1091         private SslError mSslError = null;
1092 
1093         @Override
onPageStarted(WebView view, String urlString, Bitmap favicon)1094         public void onPageStarted(WebView view, String urlString, Bitmap favicon) {
1095             if (urlString.contains(mBrowserBailOutToken)) {
1096                 mLaunchBrowser = true;
1097                 done(Result.WANTED_AS_IS);
1098                 return;
1099             }
1100             // The first page load is used only to cause the WebView to
1101             // fetch the proxy settings.  Don't update the URL bar, and
1102             // don't check if the captive portal is still there.
1103             if (mPagesLoaded == 0) {
1104                 return;
1105             }
1106             final URL url = makeURL(urlString);
1107             Log.d(TAG, "onPageStarted: " + sanitizeURL(url));
1108             // For internally generated pages, leave URL bar listing prior URL as this is the URL
1109             // the page refers to.
1110             if (!urlString.startsWith(INTERNAL_ASSETS)) {
1111                 String subtitle = (url != null) ? getHeaderSubtitle(url) : urlString;
1112                 getActionBar().setSubtitle(subtitle);
1113             }
1114             // getProgressBar() can't return null here because this method can only be
1115             // called in webview mode, not in custom tabs mode.
1116             getProgressBar().setVisibility(View.VISIBLE);
1117             mCaptivePortal.reevaluateNetwork();
1118         }
1119 
1120         @Override
onPageFinished(WebView view, String url)1121         public void onPageFinished(WebView view, String url) {
1122             mPagesLoaded++;
1123             // getProgressBar() can't return null here because this method can only be
1124             // called in webview mode, not in custom tabs mode.
1125             getProgressBar().setVisibility(View.INVISIBLE);
1126             mSwipeRefreshLayout.setRefreshing(false);
1127             if (mPagesLoaded == 1) {
1128                 // Now that WebView has loaded at least one page we know it has read in the proxy
1129                 // settings.  Now prompt the WebView read the Network-specific proxy settings.
1130                 setWebViewProxy();
1131                 // Load the real page.
1132                 view.loadUrl(mUrl.toString());
1133                 return;
1134             } else if (mPagesLoaded == 2) {
1135                 // Prevent going back to empty first page.
1136                 // Fix for missing focus, see b/62449959 for details. Remove it once we get a
1137                 // newer version of WebView (60.x.y).
1138                 view.requestFocus();
1139                 view.clearHistory();
1140             }
1141             mCaptivePortal.reevaluateNetwork();
1142         }
1143 
1144         // Convert Android scaled-pixels (sp) to HTML size.
sp(int sp)1145         private String sp(int sp) {
1146             // Convert sp to dp's.
1147             float dp = sp * mDpPerSp;
1148             // Apply a scale factor to make things look right.
1149             dp *= 1.3;
1150             // Convert dp's to HTML size.
1151             // HTML px's are scaled just like dp's, so just add "px" suffix.
1152             return Integer.toString((int)dp) + "px";
1153         }
1154 
1155         // Check if webview is trying to load the main frame and record its url.
1156         @Override
shouldOverrideUrlLoading(WebView view, WebResourceRequest request)1157         public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
1158             final String url = request.getUrl().toString();
1159             if (request.isForMainFrame()) {
1160                 mMainFrameUrls.add(url);
1161             }
1162             // Be careful that two shouldOverrideUrlLoading methods are overridden, but
1163             // shouldOverrideUrlLoading(WebView view, String url) was deprecated in API level 24.
1164             // TODO: delete deprecated one ??
1165             return shouldOverrideUrlLoading(view, url);
1166         }
1167 
1168         // Record the initial main frame url. This is only called for the initial resource URL, not
1169         // any subsequent redirect URLs.
1170         @Override
shouldInterceptRequest(WebView view, WebResourceRequest request)1171         public WebResourceResponse shouldInterceptRequest(WebView view,
1172                 WebResourceRequest request) {
1173             if (request.isForMainFrame()) {
1174                 mMainFrameUrls.add(request.getUrl().toString());
1175             }
1176             return null;
1177         }
1178 
1179         // A web page consisting of a large broken lock icon to indicate SSL failure.
1180         @Override
onReceivedSslError(WebView view, SslErrorHandler handler, SslError error)1181         public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) {
1182             final String strErrorUrl = error.getUrl();
1183             final URL errorUrl = makeURL(strErrorUrl);
1184             Log.d(TAG, String.format("SSL error: %s, url: %s, certificate: %s",
1185                     sslErrorName(error), sanitizeURL(errorUrl), error.getCertificate()));
1186             if (errorUrl == null
1187                     // Ignore SSL errors coming from subresources by comparing the
1188                     // main frame urls with SSL error url.
1189                     || (!mMainFrameUrls.contains(strErrorUrl))) {
1190                 handler.cancel();
1191                 return;
1192             }
1193             final String sslErrorPage = makeSslErrorPage();
1194             view.loadDataWithBaseURL(INTERNAL_ASSETS, sslErrorPage, "text/HTML", "UTF-8", null);
1195             mSslErrorTitle = view.getTitle() == null ? "" : view.getTitle();
1196             mSslErrorHandler = handler;
1197             mSslError = error;
1198         }
1199 
makeHtmlTag()1200         private String makeHtmlTag() {
1201             if (getWebview().getLayoutDirection() == View.LAYOUT_DIRECTION_RTL) {
1202                 return "<html dir=\"rtl\">";
1203             }
1204 
1205             return "<html>";
1206         }
1207 
1208         // If there is a VPN network or always-on VPN is enabled, there may be no way for user to
1209         // see the log-in page by browser. So, hide the link which is used to open the browser.
1210         @VisibleForTesting
getVpnMsgOrLinkToBrowser()1211         String getVpnMsgOrLinkToBrowser() {
1212             // Before Android R, CaptivePortalLogin cannot call the isAlwaysOnVpnLockdownEnabled()
1213             // to get the status of VPN always-on due to permission denied. So adding a version
1214             // check here to prevent CaptivePortalLogin crashes.
1215             if (hasVpnNetwork() || isAlwaysOnVpnEnabled()) {
1216                 final String vpnWarning = getString(R.string.no_bypass_error_vpnwarning);
1217                 return "  <div class=vpnwarning>" + vpnWarning + "</div><br>";
1218             }
1219 
1220             final String continueMsg = getString(R.string.error_continue_via_browser);
1221             return "  <a id=continue_link href=" + mBrowserBailOutToken + ">" + continueMsg
1222                     + "</a><br>";
1223         }
1224 
makeErrorPage(@tringRes int warningMsgRes, @StringRes int exampleMsgRes, String extraLink)1225         private String makeErrorPage(@StringRes int warningMsgRes, @StringRes int exampleMsgRes,
1226                 String extraLink) {
1227             final String warningMsg = getString(warningMsgRes);
1228             final String exampleMsg = getString(exampleMsgRes);
1229             return String.join("\n",
1230                     makeHtmlTag(),
1231                     "<head>",
1232                     "  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
1233                     "  <style>",
1234                     "    body {",
1235                     "      background-color:#fafafa;",
1236                     "      margin:auto;",
1237                     "      width:80%;",
1238                     "      margin-top: 96px",
1239                     "    }",
1240                     "    img {",
1241                     "      height:48px;",
1242                     "      width:48px;",
1243                     "    }",
1244                     "    div.warn {",
1245                     "      font-size:" + sp(16) + ";",
1246                     "      line-height:1.28;",
1247                     "      margin-top:16px;",
1248                     "      opacity:0.87;",
1249                     "    }",
1250                     "    div.example, div.vpnwarning {",
1251                     "      font-size:" + sp(14) + ";",
1252                     "      line-height:1.21905;",
1253                     "      margin-top:16px;",
1254                     "      opacity:0.54;",
1255                     "    }",
1256                     "    a {",
1257                     "      color:#4285F4;",
1258                     "      display:inline-block;",
1259                     "      font-size:" + sp(14) + ";",
1260                     "      font-weight:bold;",
1261                     "      margin-top:24px;",
1262                     "      text-decoration:none;",
1263                     "      text-transform:uppercase;",
1264                     "    }",
1265                     "  </style>",
1266                     "</head>",
1267                     "<body>",
1268                     "  <p><img src=quantum_ic_warning_amber_96.png><br>",
1269                     "  <div class=warn>" + warningMsg + "</div>",
1270                     "  <div class=example>" + exampleMsg + "</div>",
1271                     getVpnMsgOrLinkToBrowser(),
1272                     extraLink,
1273                     "</body>",
1274                     "</html>");
1275         }
1276 
makeCustomSchemeErrorPage()1277         private String makeCustomSchemeErrorPage() {
1278             return makeErrorPage(R.string.custom_scheme_warning, R.string.custom_scheme_example,
1279                     "" /* extraLink */);
1280         }
1281 
makeSslErrorPage()1282         private String makeSslErrorPage() {
1283             final String certificateMsg = getString(R.string.ssl_error_view_certificate);
1284             return makeErrorPage(R.string.ssl_error_warning, R.string.ssl_error_example,
1285                     "<a id=cert_link href=" + mCertificateOutToken + ">" + certificateMsg
1286                             + "</a>");
1287         }
1288 
1289         @Override
shouldOverrideUrlLoading(WebView view, String url)1290         public boolean shouldOverrideUrlLoading (WebView view, String url) {
1291             if (url.startsWith("tel:")) {
1292                 return startActivity(Intent.ACTION_DIAL, url);
1293             } else if (url.startsWith("sms:")) {
1294                 return startActivity(Intent.ACTION_SENDTO, url);
1295             } else if (!url.startsWith("http:")
1296                     && !url.startsWith("https:") && !url.startsWith(INTERNAL_ASSETS)) {
1297                 // If the page is not in a supported scheme (HTTP, HTTPS or internal page),
1298                 // show an error page that informs the user that the page is not supported. The
1299                 // user can bypass the warning and reopen the portal in browser if needed.
1300                 // This is done as it is unclear whether third party applications can properly
1301                 // handle multinetwork scenarios, if the scheme refers to a third party application.
1302                 loadCustomSchemeErrorPage(view);
1303                 return true;
1304             }
1305             if (url.contains(mCertificateOutToken) && mSslError != null) {
1306                 showSslAlertDialog(mSslErrorHandler, mSslError, mSslErrorTitle);
1307                 return true;
1308             }
1309             return false;
1310         }
1311 
startActivity(String action, String uriData)1312         private boolean startActivity(String action, String uriData) {
1313             final Intent intent = new Intent(action, Uri.parse(uriData));
1314             intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
1315             try {
1316                 CaptivePortalLoginActivity.this.startActivity(intent);
1317                 return true;
1318             } catch (ActivityNotFoundException e) {
1319                 Log.e(TAG, "No activity found to handle captive portal intent", e);
1320                 return false;
1321             }
1322         }
1323 
loadCustomSchemeErrorPage(WebView view)1324         protected void loadCustomSchemeErrorPage(WebView view) {
1325             final String errorPage = makeCustomSchemeErrorPage();
1326             view.loadDataWithBaseURL(INTERNAL_ASSETS, errorPage, "text/HTML", "UTF-8", null);
1327         }
1328 
showSslAlertDialog(SslErrorHandler handler, SslError error, String title)1329         private void showSslAlertDialog(SslErrorHandler handler, SslError error, String title) {
1330             final LayoutInflater factory = LayoutInflater.from(CaptivePortalLoginActivity.this);
1331             final View sslWarningView = factory.inflate(R.layout.ssl_warning, null);
1332 
1333             // Set Security certificate
1334             setViewSecurityCertificate(sslWarningView.findViewById(R.id.certificate_layout), error);
1335             ((TextView) sslWarningView.findViewById(R.id.ssl_error_type))
1336                     .setText(sslErrorName(error));
1337             ((TextView) sslWarningView.findViewById(R.id.title)).setText(mSslErrorTitle);
1338             ((TextView) sslWarningView.findViewById(R.id.address)).setText(error.getUrl());
1339 
1340             AlertDialog sslAlertDialog = new AlertDialog.Builder(CaptivePortalLoginActivity.this)
1341                     .setTitle(R.string.ssl_security_warning_title)
1342                     .setView(sslWarningView)
1343                     .setPositiveButton(R.string.ok, (DialogInterface dialog, int whichButton) -> {
1344                         // handler.cancel is called via OnCancelListener.
1345                         dialog.cancel();
1346                     })
1347                     .setOnCancelListener((DialogInterface dialogInterface) -> handler.cancel())
1348                     .create();
1349             sslAlertDialog.show();
1350         }
1351 
setViewSecurityCertificate(LinearLayout certificateLayout, SslError error)1352         private void setViewSecurityCertificate(LinearLayout certificateLayout, SslError error) {
1353             ((TextView) certificateLayout.findViewById(R.id.ssl_error_msg))
1354                     .setText(sslErrorMessage(error));
1355             SslCertificate cert = error.getCertificate();
1356             // TODO: call the method directly once inflateCertificateView is @SystemApi
1357             try {
1358                 final View certificateView = (View) SslCertificate.class.getMethod(
1359                         "inflateCertificateView", Context.class)
1360                         .invoke(cert, CaptivePortalLoginActivity.this);
1361                 certificateLayout.addView(certificateView);
1362             } catch (ReflectiveOperationException | SecurityException e) {
1363                 Log.e(TAG, "Could not create certificate view", e);
1364             }
1365         }
1366     }
1367 
1368     private class MyWebChromeClient extends WebChromeClient {
1369         @Override
onProgressChanged(WebView view, int newProgress)1370         public void onProgressChanged(WebView view, int newProgress) {
1371             // getProgressBar() can't return null here because this method can only be
1372             // called in webview mode, not in custom tabs mode.
1373             getProgressBar().setProgress(newProgress);
1374         }
1375     }
1376 
1377     private class PortalDownloadListener implements DownloadListener {
1378         @Override
onDownloadStart(String url, String userAgent, String contentDisposition, String mimetype, long contentLength)1379         public void onDownloadStart(String url, String userAgent, String contentDisposition,
1380                 String mimetype, long contentLength) {
1381             final String normalizedType = Intent.normalizeMimeType(mimetype);
1382             // TODO: Need to sanitize the file name.
1383             final String displayName = URLUtil.guessFileName(
1384                     url, contentDisposition, normalizedType);
1385 
1386             String guessedMimetype = normalizedType;
1387             if (TextUtils.isEmpty(guessedMimetype)) {
1388                 guessedMimetype = URLConnection.guessContentTypeFromName(displayName);
1389             }
1390             if (TextUtils.isEmpty(guessedMimetype)) {
1391                 guessedMimetype = MediaStore.Downloads.CONTENT_TYPE;
1392             }
1393 
1394             Log.d(TAG, String.format("Starting download for %s, type %s with display name %s",
1395                     url, guessedMimetype, displayName));
1396 
1397             final int requestId;
1398             // WebView should call onDownloadStart from the UI thread, but to be extra-safe as
1399             // that is not documented behavior, access the download requests array with a lock.
1400             synchronized (mDownloadRequests) {
1401                 requestId = mNextDownloadRequestId++;
1402                 // Only bind the DownloadService for the first download. The request is put into
1403                 // array later, so size == 0 with null mDownloadService means it's the first item.
1404                 if (mDownloadService == null && mDownloadRequests.size() == 0) {
1405                     final Intent serviceIntent =
1406                             new Intent(CaptivePortalLoginActivity.this, DownloadService.class);
1407                     // To allow downloads to continue while the activity is closed, start service
1408                     // with a no-op intent, to make sure the service still gets put into started
1409                     // state.
1410                     startService(new Intent(getApplicationContext(), DownloadService.class));
1411                     bindService(serviceIntent, mDownloadServiceConn, Context.BIND_AUTO_CREATE);
1412                 }
1413             }
1414             // Skip file picker for directly open MIME type, such as wifi Passpoint configuration
1415             // files. Fallback to generic design if the download process can not start successfully.
1416             if (isDirectlyOpenType(guessedMimetype)) {
1417                 try {
1418                     startDirectlyOpenDownload(url, displayName, guessedMimetype, requestId);
1419                     return;
1420                 } catch (IOException | ActivityNotFoundException e) {
1421                     // Fallthrough to show the file picker
1422                     Log.d(TAG, "Unable to do directly open on the file", e);
1423                 }
1424             }
1425 
1426             synchronized (mDownloadRequests) {
1427                 // outFile will be assigned after file is created.
1428                 mDownloadRequests.put(requestId, new DownloadRequest(url, displayName,
1429                         guessedMimetype, null /* outFile */));
1430             }
1431 
1432             final Intent createFileIntent = DownloadService.makeCreateFileIntent(
1433                     guessedMimetype, displayName);
1434             try {
1435                 startActivityForResult(createFileIntent, requestId);
1436             } catch (ActivityNotFoundException e) {
1437                 // This could happen in theory if the device has no stock document provider (which
1438                 // Android normally requires), or if the user disabled all of them, but
1439                 // should be rare; the download cannot be started as no writeable file can be
1440                 // created.
1441                 Log.e(TAG, "No document provider found to create download file", e);
1442             }
1443         }
1444 
startDirectlyOpenDownload(String url, String filename, String mimeType, int requestId)1445         private void startDirectlyOpenDownload(String url, String filename, String mimeType,
1446                 int requestId) throws ActivityNotFoundException, IOException {
1447             ensureRunningOnMainThread();
1448             // Reject another directly open task if there is one task in progress. Using
1449             // mDirectlyOpenId here is ok because mDirectlyOpenId will not be updated to
1450             // non-NO_DIRECTLY_OPEN_TASK_ID until the new task is started.
1451             if (mDirectlyOpenId != NO_DIRECTLY_OPEN_TASK_ID) {
1452                 Log.d(TAG, "Existing directly open task is in progress. Ignore this.");
1453                 return;
1454             }
1455 
1456             final File downloadPath = new File(getFilesDir(), FILE_PROVIDER_DOWNLOAD_PATH);
1457             downloadPath.mkdirs();
1458             final File file = new File(downloadPath.getPath(), filename);
1459 
1460             final Uri uri = FileProvider.getUriForFile(
1461                     CaptivePortalLoginActivity.this, getFileProviderAuthority(), file);
1462 
1463             // Test if there is possible activity to handle this directly open file.
1464             final Intent testIntent = makeDirectlyOpenIntent(uri, mimeType);
1465             if (getPackageManager().resolveActivity(testIntent, 0 /* flag */) == null) {
1466                 // No available activity is able to handle this.
1467                 throw new ActivityNotFoundException("No available activity is able to handle "
1468                         + mimeType + " mime type file");
1469             }
1470 
1471             file.createNewFile();
1472             synchronized (mDownloadRequests) {
1473                 mDownloadRequests.put(requestId, new DownloadRequest(url, filename, mimeType, uri));
1474             }
1475 
1476             maybeStartPendingDownloads();
1477         }
1478     }
1479 
1480     /**
1481      * Get the {@link androidx.core.content.FileProvider} authority for storing downloaded files.
1482      *
1483      * Useful for tests to override so they can use their own storage directories.
1484      */
1485     @VisibleForTesting
getFileProviderAuthority()1486     String getFileProviderAuthority() {
1487         return FILE_PROVIDER_AUTHORITY;
1488     }
1489 
1490     @Nullable
getProgressBar()1491     private ProgressBar getProgressBar() {
1492         return findViewById(R.id.progress_bar);
1493     }
1494 
1495     @Nullable
getWebview()1496     private WebView getWebview() {
1497         return findViewById(R.id.webview);
1498     }
1499 
1500     @Nullable
getProgressLayout()1501     private FrameLayout getProgressLayout() {
1502         return findViewById(R.id.downloading_panel);
1503     }
1504 
getHeaderTitle()1505     private String getHeaderTitle() {
1506         NetworkCapabilities nc = mCm.getNetworkCapabilities(mNetwork);
1507         final CharSequence networkName = getNetworkName(nc);
1508         if (TextUtils.isEmpty(networkName)
1509                 || nc == null || !nc.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
1510             return getString(R.string.action_bar_label);
1511         }
1512         return getString(R.string.action_bar_title, networkName);
1513     }
1514 
getNetworkName(NetworkCapabilities nc)1515     private CharSequence getNetworkName(NetworkCapabilities nc) {
1516         // Use the venue friendly name if available
1517         if (!TextUtils.isEmpty(mVenueFriendlyName)) {
1518             return mVenueFriendlyName;
1519         }
1520 
1521         // SSID is only available in NetworkCapabilities from R
1522         if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
1523             if (mWifiManager == null) {
1524                 return null;
1525             }
1526             final WifiInfo wifiInfo = getWifiConnectionInfo();
1527             return removeDoubleQuotes(wifiInfo.getSSID());
1528         }
1529 
1530         if (nc == null) {
1531             return null;
1532         }
1533         return removeDoubleQuotes(nc.getSsid());
1534     }
1535 
1536     @VisibleForTesting
getWifiConnectionInfo()1537     WifiInfo getWifiConnectionInfo() {
1538         return mWifiManager.getConnectionInfo();
1539     }
1540 
removeDoubleQuotes(String string)1541     private static String removeDoubleQuotes(String string) {
1542         if (string == null) return null;
1543         final int length = string.length();
1544         if ((length > 1) && (string.charAt(0) == '"') && (string.charAt(length - 1) == '"')) {
1545             return string.substring(1, length - 1);
1546         }
1547         return string;
1548     }
1549 
getHeaderSubtitle(URL url)1550     private String getHeaderSubtitle(URL url) {
1551         String host = host(url);
1552         final String https = "https";
1553         if (https.equals(url.getProtocol())) {
1554             return https + "://" + host;
1555         }
1556         return host;
1557     }
1558 
1559     private static final SparseArray<String> SSL_ERRORS = new SparseArray<>();
1560     static {
SSL_ERRORS.put(SslError.SSL_NOTYETVALID, "SSL_NOTYETVALID")1561         SSL_ERRORS.put(SslError.SSL_NOTYETVALID,  "SSL_NOTYETVALID");
SSL_ERRORS.put(SslError.SSL_EXPIRED, "SSL_EXPIRED")1562         SSL_ERRORS.put(SslError.SSL_EXPIRED,      "SSL_EXPIRED");
SSL_ERRORS.put(SslError.SSL_IDMISMATCH, "SSL_IDMISMATCH")1563         SSL_ERRORS.put(SslError.SSL_IDMISMATCH,   "SSL_IDMISMATCH");
SSL_ERRORS.put(SslError.SSL_UNTRUSTED, "SSL_UNTRUSTED")1564         SSL_ERRORS.put(SslError.SSL_UNTRUSTED,    "SSL_UNTRUSTED");
SSL_ERRORS.put(SslError.SSL_DATE_INVALID, "SSL_DATE_INVALID")1565         SSL_ERRORS.put(SslError.SSL_DATE_INVALID, "SSL_DATE_INVALID");
SSL_ERRORS.put(SslError.SSL_INVALID, "SSL_INVALID")1566         SSL_ERRORS.put(SslError.SSL_INVALID,      "SSL_INVALID");
1567     }
1568 
sslErrorName(SslError error)1569     private static String sslErrorName(SslError error) {
1570         return SSL_ERRORS.get(error.getPrimaryError(), "UNKNOWN");
1571     }
1572 
1573     private static final SparseArray<Integer> SSL_ERROR_MSGS = new SparseArray<>();
1574     static {
SSL_ERROR_MSGS.put(SslError.SSL_NOTYETVALID, R.string.ssl_error_not_yet_valid)1575         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)1576         SSL_ERROR_MSGS.put(SslError.SSL_EXPIRED,      R.string.ssl_error_expired);
SSL_ERROR_MSGS.put(SslError.SSL_IDMISMATCH, R.string.ssl_error_mismatch)1577         SSL_ERROR_MSGS.put(SslError.SSL_IDMISMATCH,   R.string.ssl_error_mismatch);
SSL_ERROR_MSGS.put(SslError.SSL_UNTRUSTED, R.string.ssl_error_untrusted)1578         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)1579         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)1580         SSL_ERROR_MSGS.put(SslError.SSL_INVALID,      R.string.ssl_error_invalid);
1581     }
1582 
sslErrorMessage(SslError error)1583     private static Integer sslErrorMessage(SslError error) {
1584         return SSL_ERROR_MSGS.get(error.getPrimaryError(), R.string.ssl_error_unknown);
1585     }
1586 
getVenueFriendlyName()1587     private CharSequence getVenueFriendlyName() {
1588         final LinkProperties linkProperties = mCm.getLinkProperties(mNetwork);
1589         if (linkProperties == null) {
1590             return null;
1591         }
1592         if (linkProperties.getCaptivePortalData() == null) {
1593             return null;
1594         }
1595         final CaptivePortalData captivePortalData = linkProperties.getCaptivePortalData();
1596 
1597         if (captivePortalData == null) {
1598             return null;
1599         }
1600 
1601         // TODO: Use CaptivePortalData#getVenueFriendlyName when building with S
1602         // Use reflection for now
1603         final Class captivePortalDataClass = captivePortalData.getClass();
1604         try {
1605             final Method getVenueFriendlyNameMethod = captivePortalDataClass.getDeclaredMethod(
1606                     "getVenueFriendlyName");
1607             return (CharSequence) getVenueFriendlyNameMethod.invoke(captivePortalData);
1608         } catch (Exception e) {
1609             // Do nothing
1610         }
1611         return null;
1612     }
1613 }
1614