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