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