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 import static android.provider.DeviceConfig.NAMESPACE_CONNECTIVITY; 22 23 import android.app.Activity; 24 import android.app.AlertDialog; 25 import android.app.Application; 26 import android.app.admin.DevicePolicyManager; 27 import android.content.ActivityNotFoundException; 28 import android.content.ComponentName; 29 import android.content.Context; 30 import android.content.DialogInterface; 31 import android.content.Intent; 32 import android.content.pm.PackageManager.NameNotFoundException; 33 import android.graphics.Bitmap; 34 import android.net.CaptivePortal; 35 import android.net.ConnectivityManager; 36 import android.net.ConnectivityManager.NetworkCallback; 37 import android.net.Network; 38 import android.net.NetworkCapabilities; 39 import android.net.NetworkRequest; 40 import android.net.Proxy; 41 import android.net.Uri; 42 import android.net.captiveportal.CaptivePortalProbeSpec; 43 import android.net.http.SslCertificate; 44 import android.net.http.SslError; 45 import android.net.wifi.WifiInfo; 46 import android.net.wifi.WifiManager; 47 import android.os.Build; 48 import android.os.Bundle; 49 import android.os.SystemProperties; 50 import android.provider.DeviceConfig; 51 import android.provider.MediaStore; 52 import android.text.TextUtils; 53 import android.util.ArrayMap; 54 import android.util.ArraySet; 55 import android.util.Log; 56 import android.util.SparseArray; 57 import android.util.TypedValue; 58 import android.view.LayoutInflater; 59 import android.view.Menu; 60 import android.view.MenuItem; 61 import android.view.View; 62 import android.webkit.CookieManager; 63 import android.webkit.DownloadListener; 64 import android.webkit.SslErrorHandler; 65 import android.webkit.URLUtil; 66 import android.webkit.WebChromeClient; 67 import android.webkit.WebResourceRequest; 68 import android.webkit.WebResourceResponse; 69 import android.webkit.WebSettings; 70 import android.webkit.WebView; 71 import android.webkit.WebViewClient; 72 import android.widget.LinearLayout; 73 import android.widget.ProgressBar; 74 import android.widget.TextView; 75 76 import androidx.annotation.GuardedBy; 77 import androidx.annotation.NonNull; 78 import androidx.annotation.StringRes; 79 import androidx.annotation.VisibleForTesting; 80 import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; 81 82 import com.android.internal.logging.nano.MetricsProto.MetricsEvent; 83 84 import java.io.IOException; 85 import java.lang.reflect.Field; 86 import java.lang.reflect.Method; 87 import java.net.HttpURLConnection; 88 import java.net.MalformedURLException; 89 import java.net.URL; 90 import java.net.URLConnection; 91 import java.util.Objects; 92 import java.util.Random; 93 import java.util.concurrent.atomic.AtomicBoolean; 94 95 public class CaptivePortalLoginActivity extends Activity { 96 private static final String TAG = CaptivePortalLoginActivity.class.getSimpleName(); 97 private static final boolean DBG = true; 98 private static final boolean VDBG = false; 99 100 private static final int SOCKET_TIMEOUT_MS = 10000; 101 public static final String HTTP_LOCATION_HEADER_NAME = "Location"; 102 private static final String DEFAULT_CAPTIVE_PORTAL_HTTP_URL = 103 "http://connectivitycheck.gstatic.com/generate_204"; 104 public static final String DISMISS_PORTAL_IN_VALIDATED_NETWORK = 105 "dismiss_portal_in_validated_network"; 106 107 private enum Result { 108 DISMISSED(MetricsEvent.ACTION_CAPTIVE_PORTAL_LOGIN_RESULT_DISMISSED), 109 UNWANTED(MetricsEvent.ACTION_CAPTIVE_PORTAL_LOGIN_RESULT_UNWANTED), 110 WANTED_AS_IS(MetricsEvent.ACTION_CAPTIVE_PORTAL_LOGIN_RESULT_WANTED_AS_IS); 111 112 final int metricsEvent; Result(int metricsEvent)113 Result(int metricsEvent) { this.metricsEvent = metricsEvent; } 114 }; 115 116 private URL mUrl; 117 private CaptivePortalProbeSpec mProbeSpec; 118 private String mUserAgent; 119 private Network mNetwork; 120 @VisibleForTesting 121 protected CaptivePortal mCaptivePortal; 122 private NetworkCallback mNetworkCallback; 123 private ConnectivityManager mCm; 124 private DevicePolicyManager mDpm; 125 private WifiManager mWifiManager; 126 private boolean mLaunchBrowser = false; 127 private MyWebViewClient mWebViewClient; 128 private SwipeRefreshLayout mSwipeRefreshLayout; 129 // Ensures that done() happens once exactly, handling concurrent callers with atomic operations. 130 private final AtomicBoolean isDone = new AtomicBoolean(false); 131 132 // When starting downloads a file is created via startActivityForResult(ACTION_CREATE_DOCUMENT). 133 // This array keeps the download request until the activity result is received. It is keyed by 134 // requestCode sent in startActivityForResult. 135 @GuardedBy("mDownloadRequests") 136 private final SparseArray<DownloadRequest> mDownloadRequests = new SparseArray<>(); 137 @GuardedBy("mDownloadRequests") 138 private int mNextDownloadRequestId = 1; 139 140 private static final class DownloadRequest { 141 final String mUrl; 142 final String mFilename; DownloadRequest(String url, String filename)143 DownloadRequest(String url, String filename) { 144 mUrl = url; 145 mFilename = filename; 146 } 147 } 148 149 @Override onCreate(Bundle savedInstanceState)150 protected void onCreate(Bundle savedInstanceState) { 151 super.onCreate(savedInstanceState); 152 mCaptivePortal = getIntent().getParcelableExtra(ConnectivityManager.EXTRA_CAPTIVE_PORTAL); 153 logMetricsEvent(MetricsEvent.ACTION_CAPTIVE_PORTAL_LOGIN_ACTIVITY); 154 mCm = getSystemService(ConnectivityManager.class); 155 mDpm = getSystemService(DevicePolicyManager.class); 156 mWifiManager = getSystemService(WifiManager.class); 157 mNetwork = getIntent().getParcelableExtra(ConnectivityManager.EXTRA_NETWORK); 158 mUserAgent = 159 getIntent().getStringExtra(ConnectivityManager.EXTRA_CAPTIVE_PORTAL_USER_AGENT); 160 mUrl = getUrl(); 161 if (mUrl == null) { 162 // getUrl() failed to parse the url provided in the intent: bail out in a way that 163 // at least provides network access. 164 done(Result.WANTED_AS_IS); 165 return; 166 } 167 if (DBG) { 168 Log.d(TAG, String.format("onCreate for %s", mUrl)); 169 } 170 171 final String spec = getIntent().getStringExtra(EXTRA_CAPTIVE_PORTAL_PROBE_SPEC); 172 try { 173 mProbeSpec = CaptivePortalProbeSpec.parseSpecOrNull(spec); 174 } catch (Exception e) { 175 // Make extra sure that invalid configurations do not cause crashes 176 mProbeSpec = null; 177 } 178 179 mNetworkCallback = new NetworkCallback() { 180 @Override 181 public void onLost(Network lostNetwork) { 182 // If the network disappears while the app is up, exit. 183 if (mNetwork.equals(lostNetwork)) done(Result.UNWANTED); 184 } 185 186 @Override 187 public void onCapabilitiesChanged(Network network, NetworkCapabilities nc) { 188 handleCapabilitiesChanged(network, nc); 189 } 190 }; 191 mCm.registerNetworkCallback(new NetworkRequest.Builder().build(), mNetworkCallback); 192 193 // If the network has disappeared, exit. 194 final NetworkCapabilities networkCapabilities = mCm.getNetworkCapabilities(mNetwork); 195 if (networkCapabilities == null) { 196 finishAndRemoveTask(); 197 return; 198 } 199 200 // Also initializes proxy system properties. 201 mNetwork = mNetwork.getPrivateDnsBypassingCopy(); 202 mCm.bindProcessToNetwork(mNetwork); 203 204 // Proxy system properties must be initialized before setContentView is called because 205 // setContentView initializes the WebView logic which in turn reads the system properties. 206 setContentView(R.layout.activity_captive_portal_login); 207 208 getActionBar().setDisplayShowHomeEnabled(false); 209 getActionBar().setElevation(0); // remove shadow 210 getActionBar().setTitle(getHeaderTitle()); 211 getActionBar().setSubtitle(""); 212 213 final WebView webview = getWebview(); 214 webview.clearCache(true); 215 CookieManager.getInstance().setAcceptThirdPartyCookies(webview, true); 216 WebSettings webSettings = webview.getSettings(); 217 webSettings.setJavaScriptEnabled(true); 218 webSettings.setMixedContentMode(WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE); 219 webSettings.setUseWideViewPort(true); 220 webSettings.setLoadWithOverviewMode(true); 221 webSettings.setSupportZoom(true); 222 webSettings.setBuiltInZoomControls(true); 223 webSettings.setDisplayZoomControls(false); 224 webSettings.setDomStorageEnabled(true); 225 mWebViewClient = new MyWebViewClient(); 226 webview.setWebViewClient(mWebViewClient); 227 webview.setWebChromeClient(new MyWebChromeClient()); 228 webview.setDownloadListener(new PortalDownloadListener()); 229 // Start initial page load so WebView finishes loading proxy settings. 230 // Actual load of mUrl is initiated by MyWebViewClient. 231 webview.loadData("", "text/html", null); 232 233 mSwipeRefreshLayout = findViewById(R.id.swipe_refresh); 234 mSwipeRefreshLayout.setOnRefreshListener(() -> { 235 webview.reload(); 236 mSwipeRefreshLayout.setRefreshing(true); 237 }); 238 } 239 240 @VisibleForTesting getWebViewClient()241 MyWebViewClient getWebViewClient() { 242 return mWebViewClient; 243 } 244 245 @VisibleForTesting handleCapabilitiesChanged(@onNull final Network network, @NonNull final NetworkCapabilities nc)246 void handleCapabilitiesChanged(@NonNull final Network network, 247 @NonNull final NetworkCapabilities nc) { 248 if (!isFeatureEnabled(DISMISS_PORTAL_IN_VALIDATED_NETWORK, isDismissPortalEnabled())) { 249 return; 250 } 251 252 if (network.equals(mNetwork) && nc.hasCapability(NET_CAPABILITY_VALIDATED)) { 253 // Dismiss when login is no longer needed since network has validated, exit. 254 done(Result.DISMISSED); 255 } 256 } 257 isDismissPortalEnabled()258 private boolean isDismissPortalEnabled() { 259 return Build.VERSION.SDK_INT > Build.VERSION_CODES.Q 260 || (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q 261 && !"REL".equals(Build.VERSION.CODENAME)); 262 } 263 264 // Find WebView's proxy BroadcastReceiver and prompt it to read proxy system properties. setWebViewProxy()265 private void setWebViewProxy() { 266 // TODO: migrate to androidx WebView proxy setting API as soon as it is finalized 267 try { 268 final Field loadedApkField = Application.class.getDeclaredField("mLoadedApk"); 269 final Class<?> loadedApkClass = loadedApkField.getType(); 270 final Object loadedApk = loadedApkField.get(getApplication()); 271 Field receiversField = loadedApkClass.getDeclaredField("mReceivers"); 272 receiversField.setAccessible(true); 273 ArrayMap receivers = (ArrayMap) receiversField.get(loadedApk); 274 for (Object receiverMap : receivers.values()) { 275 for (Object rec : ((ArrayMap) receiverMap).keySet()) { 276 Class clazz = rec.getClass(); 277 if (clazz.getName().contains("ProxyChangeListener")) { 278 Method onReceiveMethod = clazz.getDeclaredMethod("onReceive", Context.class, 279 Intent.class); 280 Intent intent = new Intent(Proxy.PROXY_CHANGE_ACTION); 281 onReceiveMethod.invoke(rec, getApplicationContext(), intent); 282 Log.v(TAG, "Prompting WebView proxy reload."); 283 } 284 } 285 } 286 } catch (Exception e) { 287 Log.e(TAG, "Exception while setting WebView proxy: " + e); 288 } 289 } 290 done(Result result)291 private void done(Result result) { 292 if (isDone.getAndSet(true)) { 293 // isDone was already true: done() already called 294 return; 295 } 296 if (DBG) { 297 Log.d(TAG, String.format("Result %s for %s", result.name(), mUrl)); 298 } 299 logMetricsEvent(result.metricsEvent); 300 switch (result) { 301 case DISMISSED: 302 mCaptivePortal.reportCaptivePortalDismissed(); 303 break; 304 case UNWANTED: 305 mCaptivePortal.ignoreNetwork(); 306 break; 307 case WANTED_AS_IS: 308 mCaptivePortal.useNetwork(); 309 break; 310 } 311 finishAndRemoveTask(); 312 } 313 314 @Override onCreateOptionsMenu(Menu menu)315 public boolean onCreateOptionsMenu(Menu menu) { 316 getMenuInflater().inflate(R.menu.captive_portal_login, menu); 317 return true; 318 } 319 320 @Override onBackPressed()321 public void onBackPressed() { 322 WebView myWebView = findViewById(R.id.webview); 323 if (myWebView.canGoBack() && mWebViewClient.allowBack()) { 324 myWebView.goBack(); 325 } else { 326 super.onBackPressed(); 327 } 328 } 329 330 @Override onOptionsItemSelected(MenuItem item)331 public boolean onOptionsItemSelected(MenuItem item) { 332 final Result result; 333 final String action; 334 final int id = item.getItemId(); 335 // This can't be a switch case because resource will be declared as static only but not 336 // static final as of ADT 14 in a library project. See 337 // http://tools.android.com/tips/non-constant-fields. 338 if (id == R.id.action_use_network) { 339 result = Result.WANTED_AS_IS; 340 action = "USE_NETWORK"; 341 } else if (id == R.id.action_do_not_use_network) { 342 result = Result.UNWANTED; 343 action = "DO_NOT_USE_NETWORK"; 344 } else { 345 return super.onOptionsItemSelected(item); 346 } 347 if (DBG) { 348 Log.d(TAG, String.format("onOptionsItemSelect %s for %s", action, mUrl)); 349 } 350 done(result); 351 return true; 352 } 353 354 @Override onDestroy()355 public void onDestroy() { 356 super.onDestroy(); 357 final WebView webview = (WebView) findViewById(R.id.webview); 358 if (webview != null) { 359 webview.stopLoading(); 360 webview.setWebViewClient(null); 361 webview.setWebChromeClient(null); 362 webview.destroy(); 363 } 364 if (mNetworkCallback != null) { 365 // mNetworkCallback is not null if mUrl is not null. 366 mCm.unregisterNetworkCallback(mNetworkCallback); 367 } 368 if (mLaunchBrowser) { 369 // Give time for this network to become default. After 500ms just proceed. 370 for (int i = 0; i < 5; i++) { 371 // TODO: This misses when mNetwork underlies a VPN. 372 if (mNetwork.equals(mCm.getActiveNetwork())) break; 373 try { 374 Thread.sleep(100); 375 } catch (InterruptedException e) { 376 } 377 } 378 final String url = mUrl.toString(); 379 if (DBG) { 380 Log.d(TAG, "starting activity with intent ACTION_VIEW for " + url); 381 } 382 startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url))); 383 } 384 } 385 386 @Override onActivityResult(int requestCode, int resultCode, Intent data)387 protected void onActivityResult(int requestCode, int resultCode, Intent data) { 388 if (resultCode != RESULT_OK || data == null) return; 389 390 // Start download after receiving a created file to download to 391 final DownloadRequest pendingRequest; 392 synchronized (mDownloadRequests) { 393 pendingRequest = mDownloadRequests.get(requestCode); 394 if (pendingRequest == null) { 395 Log.e(TAG, "No pending download for request " + requestCode); 396 return; 397 } 398 mDownloadRequests.remove(requestCode); 399 } 400 401 final Uri fileUri = data.getData(); 402 if (fileUri == null) { 403 Log.e(TAG, "No file received from download file creation result"); 404 return; 405 } 406 407 final Intent downloadIntent = DownloadService.makeDownloadIntent(getApplicationContext(), 408 mNetwork, mUserAgent, pendingRequest.mUrl, pendingRequest.mFilename, fileUri); 409 410 startForegroundService(downloadIntent); 411 } 412 getUrl()413 private URL getUrl() { 414 String url = getIntent().getStringExtra(ConnectivityManager.EXTRA_CAPTIVE_PORTAL_URL); 415 if (url == null) { // TODO: Have a metric to know how often empty url happened. 416 // ConnectivityManager#getCaptivePortalServerUrl is deprecated starting with Android R. 417 if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) { 418 url = DEFAULT_CAPTIVE_PORTAL_HTTP_URL; 419 } else { 420 url = mCm.getCaptivePortalServerUrl(); 421 } 422 } 423 return makeURL(url); 424 } 425 makeURL(String url)426 private static URL makeURL(String url) { 427 try { 428 return new URL(url); 429 } catch (MalformedURLException e) { 430 Log.e(TAG, "Invalid URL " + url); 431 } 432 return null; 433 } 434 host(URL url)435 private static String host(URL url) { 436 if (url == null) { 437 return null; 438 } 439 return url.getHost(); 440 } 441 sanitizeURL(URL url)442 private static String sanitizeURL(URL url) { 443 // In non-Debug build, only show host to avoid leaking private info. 444 return isDebuggable() ? Objects.toString(url) : host(url); 445 } 446 isDebuggable()447 private static boolean isDebuggable() { 448 return SystemProperties.getInt("ro.debuggable", 0) == 1; 449 } 450 reevaluateNetwork()451 private void reevaluateNetwork() { 452 if (isFeatureEnabled(DISMISS_PORTAL_IN_VALIDATED_NETWORK, isDismissPortalEnabled())) { 453 // TODO : replace this with an actual call to the method when the network stack 454 // is built against a recent enough SDK. 455 if (callVoidMethodIfExists(mCaptivePortal, "reevaluateNetwork")) return; 456 } 457 testForCaptivePortal(); 458 } 459 callVoidMethodIfExists(@onNull final Object target, @NonNull final String methodName)460 private boolean callVoidMethodIfExists(@NonNull final Object target, 461 @NonNull final String methodName) { 462 try { 463 final Method method = target.getClass().getDeclaredMethod(methodName); 464 method.invoke(target); 465 return true; 466 } catch (ReflectiveOperationException e) { 467 return false; 468 } 469 } 470 testForCaptivePortal()471 private void testForCaptivePortal() { 472 // TODO: reuse NetworkMonitor facilities for consistent captive portal detection. 473 new Thread(new Runnable() { 474 public void run() { 475 // Give time for captive portal to open. 476 try { 477 Thread.sleep(1000); 478 } catch (InterruptedException e) { 479 } 480 HttpURLConnection urlConnection = null; 481 int httpResponseCode = 500; 482 String locationHeader = null; 483 try { 484 urlConnection = (HttpURLConnection) mNetwork.openConnection(mUrl); 485 urlConnection.setInstanceFollowRedirects(false); 486 urlConnection.setConnectTimeout(SOCKET_TIMEOUT_MS); 487 urlConnection.setReadTimeout(SOCKET_TIMEOUT_MS); 488 urlConnection.setUseCaches(false); 489 if (mUserAgent != null) { 490 urlConnection.setRequestProperty("User-Agent", mUserAgent); 491 } 492 // cannot read request header after connection 493 String requestHeader = urlConnection.getRequestProperties().toString(); 494 495 urlConnection.getInputStream(); 496 httpResponseCode = urlConnection.getResponseCode(); 497 locationHeader = urlConnection.getHeaderField(HTTP_LOCATION_HEADER_NAME); 498 if (DBG) { 499 Log.d(TAG, "probe at " + mUrl + 500 " ret=" + httpResponseCode + 501 " request=" + requestHeader + 502 " headers=" + urlConnection.getHeaderFields()); 503 } 504 } catch (IOException e) { 505 } finally { 506 if (urlConnection != null) urlConnection.disconnect(); 507 } 508 if (isDismissed(httpResponseCode, locationHeader, mProbeSpec)) { 509 done(Result.DISMISSED); 510 } 511 } 512 }).start(); 513 } 514 isDismissed( int httpResponseCode, String locationHeader, CaptivePortalProbeSpec probeSpec)515 private static boolean isDismissed( 516 int httpResponseCode, String locationHeader, CaptivePortalProbeSpec probeSpec) { 517 return (probeSpec != null) 518 ? probeSpec.getResult(httpResponseCode, locationHeader).isSuccessful() 519 : (httpResponseCode == 204); 520 } 521 522 @VisibleForTesting hasVpnNetwork()523 boolean hasVpnNetwork() { 524 for (Network network : mCm.getAllNetworks()) { 525 final NetworkCapabilities nc = mCm.getNetworkCapabilities(network); 526 if (nc != null && nc.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) { 527 return true; 528 } 529 } 530 531 return false; 532 } 533 534 @VisibleForTesting isAlwaysOnVpnEnabled()535 boolean isAlwaysOnVpnEnabled() { 536 final ComponentName cn = new ComponentName(this, CaptivePortalLoginActivity.class); 537 return mDpm.isAlwaysOnVpnLockdownEnabled(cn); 538 } 539 540 @VisibleForTesting 541 class MyWebViewClient extends WebViewClient { 542 private static final String INTERNAL_ASSETS = "file:///android_asset/"; 543 544 private final String mBrowserBailOutToken = Long.toString(new Random().nextLong()); 545 private final String mCertificateOutToken = Long.toString(new Random().nextLong()); 546 // How many Android device-independent-pixels per scaled-pixel 547 // dp/sp = (px/sp) / (px/dp) = (1/sp) / (1/dp) 548 private final float mDpPerSp = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 1, 549 getResources().getDisplayMetrics()) / 550 TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1, 551 getResources().getDisplayMetrics()); 552 private int mPagesLoaded; 553 private final ArraySet<String> mMainFrameUrls = new ArraySet<>(); 554 555 // If we haven't finished cleaning up the history, don't allow going back. allowBack()556 public boolean allowBack() { 557 return mPagesLoaded > 1; 558 } 559 560 private String mSslErrorTitle = null; 561 private SslErrorHandler mSslErrorHandler = null; 562 private SslError mSslError = null; 563 564 @Override onPageStarted(WebView view, String urlString, Bitmap favicon)565 public void onPageStarted(WebView view, String urlString, Bitmap favicon) { 566 if (urlString.contains(mBrowserBailOutToken)) { 567 mLaunchBrowser = true; 568 done(Result.WANTED_AS_IS); 569 return; 570 } 571 // The first page load is used only to cause the WebView to 572 // fetch the proxy settings. Don't update the URL bar, and 573 // don't check if the captive portal is still there. 574 if (mPagesLoaded == 0) { 575 return; 576 } 577 final URL url = makeURL(urlString); 578 Log.d(TAG, "onPageStarted: " + sanitizeURL(url)); 579 // For internally generated pages, leave URL bar listing prior URL as this is the URL 580 // the page refers to. 581 if (!urlString.startsWith(INTERNAL_ASSETS)) { 582 String subtitle = (url != null) ? getHeaderSubtitle(url) : urlString; 583 getActionBar().setSubtitle(subtitle); 584 } 585 getProgressBar().setVisibility(View.VISIBLE); 586 reevaluateNetwork(); 587 } 588 589 @Override onPageFinished(WebView view, String url)590 public void onPageFinished(WebView view, String url) { 591 mPagesLoaded++; 592 getProgressBar().setVisibility(View.INVISIBLE); 593 mSwipeRefreshLayout.setRefreshing(false); 594 if (mPagesLoaded == 1) { 595 // Now that WebView has loaded at least one page we know it has read in the proxy 596 // settings. Now prompt the WebView read the Network-specific proxy settings. 597 setWebViewProxy(); 598 // Load the real page. 599 view.loadUrl(mUrl.toString()); 600 return; 601 } else if (mPagesLoaded == 2) { 602 // Prevent going back to empty first page. 603 // Fix for missing focus, see b/62449959 for details. Remove it once we get a 604 // newer version of WebView (60.x.y). 605 view.requestFocus(); 606 view.clearHistory(); 607 } 608 reevaluateNetwork(); 609 } 610 611 // Convert Android scaled-pixels (sp) to HTML size. sp(int sp)612 private String sp(int sp) { 613 // Convert sp to dp's. 614 float dp = sp * mDpPerSp; 615 // Apply a scale factor to make things look right. 616 dp *= 1.3; 617 // Convert dp's to HTML size. 618 // HTML px's are scaled just like dp's, so just add "px" suffix. 619 return Integer.toString((int)dp) + "px"; 620 } 621 622 // Check if webview is trying to load the main frame and record its url. 623 @Override shouldOverrideUrlLoading(WebView view, WebResourceRequest request)624 public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { 625 final String url = request.getUrl().toString(); 626 if (request.isForMainFrame()) { 627 mMainFrameUrls.add(url); 628 } 629 // Be careful that two shouldOverrideUrlLoading methods are overridden, but 630 // shouldOverrideUrlLoading(WebView view, String url) was deprecated in API level 24. 631 // TODO: delete deprecated one ?? 632 return shouldOverrideUrlLoading(view, url); 633 } 634 635 // Record the initial main frame url. This is only called for the initial resource URL, not 636 // any subsequent redirect URLs. 637 @Override shouldInterceptRequest(WebView view, WebResourceRequest request)638 public WebResourceResponse shouldInterceptRequest(WebView view, 639 WebResourceRequest request) { 640 if (request.isForMainFrame()) { 641 mMainFrameUrls.add(request.getUrl().toString()); 642 } 643 return null; 644 } 645 646 // A web page consisting of a large broken lock icon to indicate SSL failure. 647 @Override onReceivedSslError(WebView view, SslErrorHandler handler, SslError error)648 public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) { 649 final String strErrorUrl = error.getUrl(); 650 final URL errorUrl = makeURL(strErrorUrl); 651 Log.d(TAG, String.format("SSL error: %s, url: %s, certificate: %s", 652 sslErrorName(error), sanitizeURL(errorUrl), error.getCertificate())); 653 if (errorUrl == null 654 // Ignore SSL errors coming from subresources by comparing the 655 // main frame urls with SSL error url. 656 || (!mMainFrameUrls.contains(strErrorUrl))) { 657 handler.cancel(); 658 return; 659 } 660 logMetricsEvent(MetricsEvent.CAPTIVE_PORTAL_LOGIN_ACTIVITY_SSL_ERROR); 661 final String sslErrorPage = makeSslErrorPage(); 662 view.loadDataWithBaseURL(INTERNAL_ASSETS, sslErrorPage, "text/HTML", "UTF-8", null); 663 mSslErrorTitle = view.getTitle() == null ? "" : view.getTitle(); 664 mSslErrorHandler = handler; 665 mSslError = error; 666 } 667 makeHtmlTag()668 private String makeHtmlTag() { 669 if (getWebview().getLayoutDirection() == View.LAYOUT_DIRECTION_RTL) { 670 return "<html dir=\"rtl\">"; 671 } 672 673 return "<html>"; 674 } 675 676 // If there is a VPN network or always-on VPN is enabled, there may be no way for user to 677 // see the log-in page by browser. So, hide the link which is used to open the browser. 678 @VisibleForTesting getVpnMsgOrLinkToBrowser()679 String getVpnMsgOrLinkToBrowser() { 680 if (isAlwaysOnVpnEnabled() || hasVpnNetwork()) { 681 final String vpnWarning = getString(R.string.no_bypass_error_vpnwarning); 682 return " <div class=vpnwarning>" + vpnWarning + "</div><br>"; 683 } 684 685 final String continueMsg = getString(R.string.error_continue_via_browser); 686 return " <a id=continue_link href=" + mBrowserBailOutToken + ">" + continueMsg 687 + "</a><br>"; 688 } 689 makeErrorPage(@tringRes int warningMsgRes, @StringRes int exampleMsgRes, String extraLink)690 private String makeErrorPage(@StringRes int warningMsgRes, @StringRes int exampleMsgRes, 691 String extraLink) { 692 final String warningMsg = getString(warningMsgRes); 693 final String exampleMsg = getString(exampleMsgRes); 694 return String.join("\n", 695 makeHtmlTag(), 696 "<head>", 697 " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">", 698 " <style>", 699 " body {", 700 " background-color:#fafafa;", 701 " margin:auto;", 702 " width:80%;", 703 " margin-top: 96px", 704 " }", 705 " img {", 706 " height:48px;", 707 " width:48px;", 708 " }", 709 " div.warn {", 710 " font-size:" + sp(16) + ";", 711 " line-height:1.28;", 712 " margin-top:16px;", 713 " opacity:0.87;", 714 " }", 715 " div.example, div.vpnwarning {", 716 " font-size:" + sp(14) + ";", 717 " line-height:1.21905;", 718 " margin-top:16px;", 719 " opacity:0.54;", 720 " }", 721 " a {", 722 " color:#4285F4;", 723 " display:inline-block;", 724 " font-size:" + sp(14) + ";", 725 " font-weight:bold;", 726 " height:48px;", 727 " margin-top:24px;", 728 " text-decoration:none;", 729 " text-transform:uppercase;", 730 " }", 731 " a#cert_link {", 732 " margin-top:0px;", 733 " }", 734 " </style>", 735 "</head>", 736 "<body>", 737 " <p><img src=quantum_ic_warning_amber_96.png><br>", 738 " <div class=warn>" + warningMsg + "</div>", 739 " <div class=example>" + exampleMsg + "</div>", 740 getVpnMsgOrLinkToBrowser(), 741 extraLink, 742 "</body>", 743 "</html>"); 744 } 745 makeCustomSchemeErrorPage()746 private String makeCustomSchemeErrorPage() { 747 return makeErrorPage(R.string.custom_scheme_warning, R.string.custom_scheme_example, 748 "" /* extraLink */); 749 } 750 makeSslErrorPage()751 private String makeSslErrorPage() { 752 final String certificateMsg = getString(R.string.ssl_error_view_certificate); 753 return makeErrorPage(R.string.ssl_error_warning, R.string.ssl_error_example, 754 "<a id=cert_link href=" + mCertificateOutToken + ">" + certificateMsg 755 + "</a>"); 756 } 757 758 @Override shouldOverrideUrlLoading(WebView view, String url)759 public boolean shouldOverrideUrlLoading (WebView view, String url) { 760 if (url.startsWith("tel:")) { 761 return startActivity(Intent.ACTION_DIAL, url); 762 } else if (url.startsWith("sms:")) { 763 return startActivity(Intent.ACTION_SENDTO, url); 764 } else if (!url.startsWith("http:") 765 && !url.startsWith("https:") && !url.startsWith(INTERNAL_ASSETS)) { 766 // If the page is not in a supported scheme (HTTP, HTTPS or internal page), 767 // show an error page that informs the user that the page is not supported. The 768 // user can bypass the warning and reopen the portal in browser if needed. 769 // This is done as it is unclear whether third party applications can properly 770 // handle multinetwork scenarios, if the scheme refers to a third party application. 771 loadCustomSchemeErrorPage(view); 772 return true; 773 } 774 if (url.contains(mCertificateOutToken) && mSslError != null) { 775 showSslAlertDialog(mSslErrorHandler, mSslError, mSslErrorTitle); 776 return true; 777 } 778 return false; 779 } 780 startActivity(String action, String uriData)781 private boolean startActivity(String action, String uriData) { 782 final Intent intent = new Intent(action, Uri.parse(uriData)); 783 intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); 784 try { 785 CaptivePortalLoginActivity.this.startActivity(intent); 786 return true; 787 } catch (ActivityNotFoundException e) { 788 Log.e(TAG, "No activity found to handle captive portal intent", e); 789 return false; 790 } 791 } 792 loadCustomSchemeErrorPage(WebView view)793 protected void loadCustomSchemeErrorPage(WebView view) { 794 final String errorPage = makeCustomSchemeErrorPage(); 795 view.loadDataWithBaseURL(INTERNAL_ASSETS, errorPage, "text/HTML", "UTF-8", null); 796 } 797 showSslAlertDialog(SslErrorHandler handler, SslError error, String title)798 private void showSslAlertDialog(SslErrorHandler handler, SslError error, String title) { 799 final LayoutInflater factory = LayoutInflater.from(CaptivePortalLoginActivity.this); 800 final View sslWarningView = factory.inflate(R.layout.ssl_warning, null); 801 802 // Set Security certificate 803 setViewSecurityCertificate(sslWarningView.findViewById(R.id.certificate_layout), error); 804 ((TextView) sslWarningView.findViewById(R.id.ssl_error_type)) 805 .setText(sslErrorName(error)); 806 ((TextView) sslWarningView.findViewById(R.id.title)).setText(mSslErrorTitle); 807 ((TextView) sslWarningView.findViewById(R.id.address)).setText(error.getUrl()); 808 809 AlertDialog sslAlertDialog = new AlertDialog.Builder(CaptivePortalLoginActivity.this) 810 .setTitle(R.string.ssl_security_warning_title) 811 .setView(sslWarningView) 812 .setPositiveButton(R.string.ok, (DialogInterface dialog, int whichButton) -> { 813 // handler.cancel is called via OnCancelListener. 814 dialog.cancel(); 815 }) 816 .setOnCancelListener((DialogInterface dialogInterface) -> handler.cancel()) 817 .create(); 818 sslAlertDialog.show(); 819 } 820 setViewSecurityCertificate(LinearLayout certificateLayout, SslError error)821 private void setViewSecurityCertificate(LinearLayout certificateLayout, SslError error) { 822 ((TextView) certificateLayout.findViewById(R.id.ssl_error_msg)) 823 .setText(sslErrorMessage(error)); 824 SslCertificate cert = error.getCertificate(); 825 // TODO: call the method directly once inflateCertificateView is @SystemApi 826 try { 827 final View certificateView = (View) SslCertificate.class.getMethod( 828 "inflateCertificateView", Context.class) 829 .invoke(cert, CaptivePortalLoginActivity.this); 830 certificateLayout.addView(certificateView); 831 } catch (ReflectiveOperationException | SecurityException e) { 832 Log.e(TAG, "Could not create certificate view", e); 833 } 834 } 835 } 836 837 private class MyWebChromeClient extends WebChromeClient { 838 @Override onProgressChanged(WebView view, int newProgress)839 public void onProgressChanged(WebView view, int newProgress) { 840 getProgressBar().setProgress(newProgress); 841 } 842 } 843 844 private class PortalDownloadListener implements DownloadListener { 845 @Override onDownloadStart(String url, String userAgent, String contentDisposition, String mimetype, long contentLength)846 public void onDownloadStart(String url, String userAgent, String contentDisposition, 847 String mimetype, long contentLength) { 848 final String normalizedType = Intent.normalizeMimeType(mimetype); 849 final String displayName = URLUtil.guessFileName(url, contentDisposition, 850 normalizedType); 851 852 String guessedMimetype = normalizedType; 853 if (TextUtils.isEmpty(guessedMimetype)) { 854 guessedMimetype = URLConnection.guessContentTypeFromName(displayName); 855 } 856 if (TextUtils.isEmpty(guessedMimetype)) { 857 guessedMimetype = MediaStore.Downloads.CONTENT_TYPE; 858 } 859 860 Log.d(TAG, String.format("Starting download for %s, type %s with display name %s", 861 url, guessedMimetype, displayName)); 862 863 final Intent createFileIntent = DownloadService.makeCreateFileIntent( 864 guessedMimetype, displayName); 865 866 final int requestId; 867 // WebView should call onDownloadStart from the UI thread, but to be extra-safe as 868 // that is not documented behavior, access the download requests array with a lock. 869 synchronized (mDownloadRequests) { 870 requestId = mNextDownloadRequestId++; 871 mDownloadRequests.put(requestId, new DownloadRequest(url, displayName)); 872 } 873 874 try { 875 startActivityForResult(createFileIntent, requestId); 876 } catch (ActivityNotFoundException e) { 877 // This could happen in theory if the device has no stock document provider (which 878 // Android normally requires), or if the user disabled all of them, but 879 // should be rare; the download cannot be started as no writeable file can be 880 // created. 881 Log.e(TAG, "No document provider found to create download file", e); 882 } 883 } 884 } 885 getProgressBar()886 private ProgressBar getProgressBar() { 887 return findViewById(R.id.progress_bar); 888 } 889 getWebview()890 private WebView getWebview() { 891 return findViewById(R.id.webview); 892 } 893 getHeaderTitle()894 private String getHeaderTitle() { 895 NetworkCapabilities nc = mCm.getNetworkCapabilities(mNetwork); 896 final String ssid = getSsid(); 897 if (TextUtils.isEmpty(ssid) 898 || nc == null || !nc.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { 899 return getString(R.string.action_bar_label); 900 } 901 return getString(R.string.action_bar_title, ssid); 902 } 903 904 // TODO: remove once SSID is obtained from NetworkCapabilities getSsid()905 private String getSsid() { 906 if (mWifiManager == null) { 907 return null; 908 } 909 final WifiInfo wifiInfo = mWifiManager.getConnectionInfo(); 910 return removeDoubleQuotes(wifiInfo.getSSID()); 911 } 912 removeDoubleQuotes(String string)913 private static String removeDoubleQuotes(String string) { 914 if (string == null) return null; 915 final int length = string.length(); 916 if ((length > 1) && (string.charAt(0) == '"') && (string.charAt(length - 1) == '"')) { 917 return string.substring(1, length - 1); 918 } 919 return string; 920 } 921 getHeaderSubtitle(URL url)922 private String getHeaderSubtitle(URL url) { 923 String host = host(url); 924 final String https = "https"; 925 if (https.equals(url.getProtocol())) { 926 return https + "://" + host; 927 } 928 return host; 929 } 930 logMetricsEvent(int event)931 private void logMetricsEvent(int event) { 932 mCaptivePortal.logEvent(event, getPackageName()); 933 } 934 935 private static final SparseArray<String> SSL_ERRORS = new SparseArray<>(); 936 static { SSL_ERRORS.put(SslError.SSL_NOTYETVALID, "SSL_NOTYETVALID")937 SSL_ERRORS.put(SslError.SSL_NOTYETVALID, "SSL_NOTYETVALID"); SSL_ERRORS.put(SslError.SSL_EXPIRED, "SSL_EXPIRED")938 SSL_ERRORS.put(SslError.SSL_EXPIRED, "SSL_EXPIRED"); SSL_ERRORS.put(SslError.SSL_IDMISMATCH, "SSL_IDMISMATCH")939 SSL_ERRORS.put(SslError.SSL_IDMISMATCH, "SSL_IDMISMATCH"); SSL_ERRORS.put(SslError.SSL_UNTRUSTED, "SSL_UNTRUSTED")940 SSL_ERRORS.put(SslError.SSL_UNTRUSTED, "SSL_UNTRUSTED"); SSL_ERRORS.put(SslError.SSL_DATE_INVALID, "SSL_DATE_INVALID")941 SSL_ERRORS.put(SslError.SSL_DATE_INVALID, "SSL_DATE_INVALID"); SSL_ERRORS.put(SslError.SSL_INVALID, "SSL_INVALID")942 SSL_ERRORS.put(SslError.SSL_INVALID, "SSL_INVALID"); 943 } 944 sslErrorName(SslError error)945 private static String sslErrorName(SslError error) { 946 return SSL_ERRORS.get(error.getPrimaryError(), "UNKNOWN"); 947 } 948 949 private static final SparseArray<Integer> SSL_ERROR_MSGS = new SparseArray<>(); 950 static { SSL_ERROR_MSGS.put(SslError.SSL_NOTYETVALID, R.string.ssl_error_not_yet_valid)951 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)952 SSL_ERROR_MSGS.put(SslError.SSL_EXPIRED, R.string.ssl_error_expired); SSL_ERROR_MSGS.put(SslError.SSL_IDMISMATCH, R.string.ssl_error_mismatch)953 SSL_ERROR_MSGS.put(SslError.SSL_IDMISMATCH, R.string.ssl_error_mismatch); SSL_ERROR_MSGS.put(SslError.SSL_UNTRUSTED, R.string.ssl_error_untrusted)954 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)955 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)956 SSL_ERROR_MSGS.put(SslError.SSL_INVALID, R.string.ssl_error_invalid); 957 } 958 sslErrorMessage(SslError error)959 private static Integer sslErrorMessage(SslError error) { 960 return SSL_ERROR_MSGS.get(error.getPrimaryError(), R.string.ssl_error_unknown); 961 } 962 isFeatureEnabled(@onNull final String name, final boolean defaultEnabled)963 private boolean isFeatureEnabled(@NonNull final String name, final boolean defaultEnabled) { 964 final long propertyVersion = DeviceConfig.getLong(NAMESPACE_CONNECTIVITY, name, 0); 965 long mPackageVersion = 0; 966 try { 967 mPackageVersion = getPackageManager().getPackageInfo( 968 getPackageName(), 0).getLongVersionCode(); 969 } catch (NameNotFoundException e) { 970 Log.e(TAG, "Could not find the package name", e); 971 } 972 return (propertyVersion == 0 && defaultEnabled) 973 || (propertyVersion != 0 && mPackageVersion >= propertyVersion); 974 } 975 } 976