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