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