1 /* 2 * Copyright (C) 2017 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.carrierdefaultapp; 18 19 import android.app.Activity; 20 import android.app.LoadedApk; 21 import android.content.Context; 22 import android.content.Intent; 23 import android.content.pm.ActivityInfo; 24 import android.content.pm.PackageInfo; 25 import android.content.pm.PackageManager; 26 import android.graphics.Bitmap; 27 import android.net.ConnectivityManager; 28 import android.net.ConnectivityManager.NetworkCallback; 29 import android.net.Network; 30 import android.net.NetworkCapabilities; 31 import android.net.NetworkRequest; 32 import android.net.Proxy; 33 import android.net.TrafficStats; 34 import android.net.Uri; 35 import android.net.http.SslError; 36 import android.os.Bundle; 37 import android.telephony.CarrierConfigManager; 38 import android.telephony.SubscriptionManager; 39 import android.telephony.TelephonyManager; 40 import android.text.TextUtils; 41 import android.util.ArrayMap; 42 import android.util.Log; 43 import android.util.TypedValue; 44 import android.webkit.SslErrorHandler; 45 import android.webkit.WebChromeClient; 46 import android.webkit.WebSettings; 47 import android.webkit.WebView; 48 import android.webkit.WebViewClient; 49 import android.widget.ProgressBar; 50 import android.widget.TextView; 51 52 import com.android.internal.util.ArrayUtils; 53 import com.android.net.module.util.NetworkStackConstants; 54 55 import java.io.IOException; 56 import java.lang.reflect.Field; 57 import java.lang.reflect.Method; 58 import java.net.HttpURLConnection; 59 import java.net.MalformedURLException; 60 import java.net.URL; 61 import java.util.Random; 62 63 /** 64 * Activity that launches in response to the captive portal notification 65 * @see com.android.carrierdefaultapp.CarrierActionUtils#CARRIER_ACTION_SHOW_PORTAL_NOTIFICATION 66 * This activity requests network connection if there is no available one before loading the real 67 * portal page and apply carrier actions on the portal activation result. 68 */ 69 public class CaptivePortalLoginActivity extends Activity { 70 private static final String TAG = CaptivePortalLoginActivity.class.getSimpleName(); 71 private static final boolean DBG = true; 72 73 private static final int SOCKET_TIMEOUT_MS = 10 * 1000; 74 private static final int NETWORK_REQUEST_TIMEOUT_MS = 5 * 1000; 75 76 private URL mUrl; 77 private Network mNetwork; 78 private NetworkCallback mNetworkCallback; 79 private ConnectivityManager mCm; 80 private WebView mWebView; 81 private MyWebViewClient mWebViewClient; 82 private boolean mLaunchBrowser = false; 83 private Thread mTestingThread = null; 84 private boolean mReload = false; 85 86 @Override onCreate(Bundle savedInstanceState)87 protected void onCreate(Bundle savedInstanceState) { 88 super.onCreate(savedInstanceState); 89 mCm = getSystemService(ConnectivityManager.class); 90 mUrl = getUrlForCaptivePortal(); 91 if (mUrl == null) { 92 done(false); 93 return; 94 } 95 if (DBG) logd(String.format("onCreate for %s", mUrl.toString())); 96 setContentView(R.layout.activity_captive_portal_login); 97 getActionBar().setDisplayShowHomeEnabled(false); 98 99 mWebView = findViewById(R.id.webview); 100 mWebView.clearCache(true); 101 WebSettings webSettings = mWebView.getSettings(); 102 webSettings.setJavaScriptEnabled(true); 103 webSettings.setMixedContentMode(WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE); 104 webSettings.setUseWideViewPort(true); 105 webSettings.setLoadWithOverviewMode(true); 106 webSettings.setSupportZoom(true); 107 webSettings.setBuiltInZoomControls(true); 108 webSettings.setDomStorageEnabled(true); 109 webSettings.setAllowFileAccess(false); 110 mWebViewClient = new MyWebViewClient(); 111 mWebView.setWebViewClient(mWebViewClient); 112 mWebView.setWebChromeClient(new MyWebChromeClient()); 113 114 final Network network = getNetworkForCaptivePortal(); 115 if (network == null) { 116 requestNetworkForCaptivePortal(); 117 } else { 118 setNetwork(network); 119 // Start initial page load so WebView finishes loading proxy settings. 120 // Actual load of mUrl is initiated by MyWebViewClient. 121 mWebView.loadData("", "text/html", null); 122 } 123 } 124 125 @Override onBackPressed()126 public void onBackPressed() { 127 WebView myWebView = findViewById(R.id.webview); 128 if (myWebView.canGoBack() && mWebViewClient.allowBack()) { 129 myWebView.goBack(); 130 } else { 131 super.onBackPressed(); 132 } 133 } 134 135 @Override onDestroy()136 public void onDestroy() { 137 if (mLaunchBrowser) { 138 // Give time for this network to become default. After 500ms just proceed. 139 for (int i = 0; i < 5; i++) { 140 // TODO: This misses when mNetwork underlies a VPN. 141 if (mNetwork.equals(mCm.getActiveNetwork())) break; 142 try { 143 Thread.sleep(100); 144 } catch (InterruptedException e) { 145 } 146 } 147 final String url = mUrl.toString(); 148 if (DBG) logd("starting activity with intent ACTION_VIEW for " + url); 149 startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url))); 150 } 151 152 if (mTestingThread != null) { 153 mTestingThread.interrupt(); 154 } 155 mWebView.destroy(); 156 releaseNetworkRequest(); 157 super.onDestroy(); 158 } 159 setNetwork(Network network)160 private void setNetwork(Network network) { 161 if (network != null) { 162 network = network.getPrivateDnsBypassingCopy(); 163 mCm.bindProcessToNetwork(network); 164 } 165 mNetwork = network; 166 } 167 168 // Find WebView's proxy BroadcastReceiver and prompt it to read proxy system properties. setWebViewProxy()169 private void setWebViewProxy() { 170 LoadedApk loadedApk = getApplication().mLoadedApk; 171 try { 172 Field receiversField = LoadedApk.class.getDeclaredField("mReceivers"); 173 receiversField.setAccessible(true); 174 ArrayMap receivers = (ArrayMap) receiversField.get(loadedApk); 175 for (Object receiverMap : receivers.values()) { 176 for (Object rec : ((ArrayMap) receiverMap).keySet()) { 177 Class clazz = rec.getClass(); 178 if (clazz.getName().contains("ProxyChangeListener")) { 179 Method onReceiveMethod = clazz.getDeclaredMethod("onReceive", Context.class, 180 Intent.class); 181 Intent intent = new Intent(Proxy.PROXY_CHANGE_ACTION); 182 onReceiveMethod.invoke(rec, getApplicationContext(), intent); 183 Log.v(TAG, "Prompting WebView proxy reload."); 184 } 185 } 186 } 187 } catch (Exception e) { 188 loge("Exception while setting WebView proxy: " + e); 189 } 190 } 191 done(boolean success)192 private void done(boolean success) { 193 if (DBG) logd(String.format("Result success %b for %s", success, 194 mUrl != null ? mUrl.toString() : "null")); 195 if (success) { 196 // Trigger re-evaluation upon success http response code 197 CarrierActionUtils.applyCarrierAction( 198 CarrierActionUtils.CARRIER_ACTION_RESET_ALL, getIntent(), 199 getApplicationContext()); 200 } 201 finishAndRemoveTask(); 202 } 203 getUrlForCaptivePortal()204 private URL getUrlForCaptivePortal() { 205 String url = getIntent().getStringExtra(TelephonyManager.EXTRA_REDIRECTION_URL); 206 if (TextUtils.isEmpty(url)) url = mCm.getCaptivePortalServerUrl(); 207 final CarrierConfigManager configManager = getApplicationContext() 208 .getSystemService(CarrierConfigManager.class); 209 final int subId = getIntent().getIntExtra(SubscriptionManager.EXTRA_SUBSCRIPTION_INDEX, 210 SubscriptionManager.getDefaultVoiceSubscriptionId()); 211 final String[] portalURLs = configManager.getConfigForSubId(subId).getStringArray( 212 CarrierConfigManager.KEY_CARRIER_DEFAULT_REDIRECTION_URL_STRING_ARRAY); 213 if (!ArrayUtils.isEmpty(portalURLs)) { 214 for (String portalUrl : portalURLs) { 215 if (url.startsWith(portalUrl)) { 216 break; 217 } 218 } 219 url = null; 220 } 221 try { 222 return new URL(url); 223 } catch (MalformedURLException e) { 224 loge("Invalid captive portal URL " + url); 225 } 226 return null; 227 } 228 testForCaptivePortal()229 private void testForCaptivePortal() { 230 mTestingThread = new Thread(new Runnable() { 231 public void run() { 232 // Give time for captive portal to open. 233 try { 234 Thread.sleep(1000); 235 } catch (InterruptedException e) { 236 } 237 if (isFinishing() || isDestroyed()) return; 238 HttpURLConnection urlConnection = null; 239 int httpResponseCode = 500; 240 int oldTag = TrafficStats.getAndSetThreadStatsTag( 241 NetworkStackConstants.TAG_SYSTEM_PROBE); 242 try { 243 urlConnection = (HttpURLConnection) mNetwork.openConnection( 244 new URL(mCm.getCaptivePortalServerUrl())); 245 urlConnection.setInstanceFollowRedirects(false); 246 urlConnection.setConnectTimeout(SOCKET_TIMEOUT_MS); 247 urlConnection.setReadTimeout(SOCKET_TIMEOUT_MS); 248 urlConnection.setUseCaches(false); 249 urlConnection.getInputStream(); 250 httpResponseCode = urlConnection.getResponseCode(); 251 } catch (IOException e) { 252 loge(e.getMessage()); 253 } finally { 254 if (urlConnection != null) urlConnection.disconnect(); 255 TrafficStats.setThreadStatsTag(oldTag); 256 } 257 if (httpResponseCode == 204) { 258 done(true); 259 } 260 } 261 }); 262 mTestingThread.start(); 263 } 264 getNetworkForCaptivePortal()265 private Network getNetworkForCaptivePortal() { 266 Network[] info = mCm.getAllNetworks(); 267 if (!ArrayUtils.isEmpty(info)) { 268 for (Network nw : info) { 269 final NetworkCapabilities nc = mCm.getNetworkCapabilities(nw); 270 if (nc.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) 271 && nc.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) { 272 return nw; 273 } 274 } 275 } 276 return null; 277 } 278 requestNetworkForCaptivePortal()279 private void requestNetworkForCaptivePortal() { 280 NetworkRequest request = new NetworkRequest.Builder() 281 .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR) 282 .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) 283 .removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED) 284 .build(); 285 286 mNetworkCallback = new ConnectivityManager.NetworkCallback() { 287 @Override 288 public void onAvailable(Network network) { 289 if (DBG) logd("Network available: " + network); 290 setNetwork(network); 291 runOnUiThreadIfNotFinishing(() -> { 292 if (mReload) { 293 mWebView.reload(); 294 } else { 295 // Start initial page load so WebView finishes loading proxy settings. 296 // Actual load of mUrl is initiated by MyWebViewClient. 297 mWebView.loadData("", "text/html", null); 298 } 299 }); 300 } 301 302 @Override 303 public void onUnavailable() { 304 if (DBG) logd("Network unavailable"); 305 runOnUiThreadIfNotFinishing(() -> { 306 // Instead of not loading anything in webview, simply load the page and return 307 // HTTP error page in the absence of network connection. 308 mWebView.loadUrl(mUrl.toString()); 309 }); 310 } 311 312 @Override 313 public void onLost(Network lostNetwork) { 314 if (DBG) logd("Network lost"); 315 mReload = true; 316 } 317 }; 318 logd("request Network for captive portal"); 319 mCm.requestNetwork(request, mNetworkCallback, NETWORK_REQUEST_TIMEOUT_MS); 320 } 321 releaseNetworkRequest()322 private void releaseNetworkRequest() { 323 logd("release Network for captive portal"); 324 if (mNetworkCallback != null) { 325 mCm.unregisterNetworkCallback(mNetworkCallback); 326 mNetworkCallback = null; 327 mNetwork = null; 328 } 329 } 330 331 private class MyWebViewClient extends WebViewClient { 332 private static final String INTERNAL_ASSETS = "file:///android_asset/"; 333 private final String mBrowserBailOutToken = Long.toString(new Random().nextLong()); 334 // How many Android device-independent-pixels per scaled-pixel 335 // dp/sp = (px/sp) / (px/dp) = (1/sp) / (1/dp) 336 private final float mDpPerSp = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 1, 337 getResources().getDisplayMetrics()) 338 / TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1, 339 getResources().getDisplayMetrics()); 340 private int mPagesLoaded; 341 342 // If we haven't finished cleaning up the history, don't allow going back. allowBack()343 public boolean allowBack() { 344 return mPagesLoaded > 1; 345 } 346 347 @Override onPageStarted(WebView view, String url, Bitmap favicon)348 public void onPageStarted(WebView view, String url, Bitmap favicon) { 349 if (url.contains(mBrowserBailOutToken)) { 350 mLaunchBrowser = true; 351 done(false); 352 return; 353 } 354 // The first page load is used only to cause the WebView to 355 // fetch the proxy settings. Don't update the URL bar, and 356 // don't check if the captive portal is still there. 357 if (mPagesLoaded == 0) return; 358 // For internally generated pages, leave URL bar listing prior URL as this is the URL 359 // the page refers to. 360 if (!url.startsWith(INTERNAL_ASSETS)) { 361 final TextView myUrlBar = findViewById(R.id.url_bar); 362 myUrlBar.setText(url); 363 } 364 if (mNetwork != null) { 365 testForCaptivePortal(); 366 } 367 } 368 369 @Override onPageFinished(WebView view, String url)370 public void onPageFinished(WebView view, String url) { 371 mPagesLoaded++; 372 if (mPagesLoaded == 1) { 373 // Now that WebView has loaded at least one page we know it has read in the proxy 374 // settings. Now prompt the WebView read the Network-specific proxy settings. 375 setWebViewProxy(); 376 // Load the real page. 377 view.loadUrl(mUrl.toString()); 378 return; 379 } else if (mPagesLoaded == 2) { 380 // Prevent going back to empty first page. 381 view.clearHistory(); 382 } 383 if (mNetwork != null) { 384 testForCaptivePortal(); 385 } 386 } 387 388 // Convert Android device-independent-pixels (dp) to HTML size. dp(int dp)389 private String dp(int dp) { 390 // HTML px's are scaled just like dp's, so just add "px" suffix. 391 return Integer.toString(dp) + "px"; 392 } 393 394 // Convert Android scaled-pixels (sp) to HTML size. sp(int sp)395 private String sp(int sp) { 396 // Convert sp to dp's. 397 float dp = sp * mDpPerSp; 398 // Apply a scale factor to make things look right. 399 dp *= 1.3; 400 // Convert dp's to HTML size. 401 return dp((int) dp); 402 } 403 404 // A web page consisting of a large broken lock icon to indicate SSL failure. 405 private final String SSL_ERROR_HTML = "<html><head><style>" 406 + "body { margin-left:" + dp(48) + "; margin-right:" + dp(48) + "; " 407 + "margin-top:" + dp(96) + "; background-color:#fafafa; }" 408 + "img { width:" + dp(48) + "; height:" + dp(48) + "; }" 409 + "div.warn { font-size:" + sp(16) + "; margin-top:" + dp(16) + "; " 410 + " opacity:0.87; line-height:1.28; }" 411 + "div.example { font-size:" + sp(14) + "; margin-top:" + dp(16) + "; " 412 + " opacity:0.54; line-height:1.21905; }" 413 + "a { font-size:" + sp(14) + "; text-decoration:none; text-transform:uppercase; " 414 + " margin-top:" + dp(24) + "; display:inline-block; color:#4285F4; " 415 + " height:" + dp(48) + "; font-weight:bold; }" 416 + "</style></head><body><p><img src=quantum_ic_warning_amber_96.png><br>" 417 + "<div class=warn>%s</div>" 418 + "<div class=example>%s</div>" + "<a href=%s>%s</a></body></html>"; 419 420 @Override onReceivedSslError(WebView view, SslErrorHandler handler, SslError error)421 public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) { 422 Log.w(TAG, "SSL error (error: " + error.getPrimaryError() + " host: " 423 // Only show host to avoid leaking private info. 424 + Uri.parse(error.getUrl()).getHost() + " certificate: " 425 + error.getCertificate() + "); displaying SSL warning."); 426 final String html = String.format(SSL_ERROR_HTML, getString(R.string.ssl_error_warning), 427 getString(R.string.ssl_error_example), mBrowserBailOutToken, 428 getString(R.string.ssl_error_continue)); 429 view.loadDataWithBaseURL(INTERNAL_ASSETS, html, "text/HTML", "UTF-8", null); 430 } 431 432 @Override shouldOverrideUrlLoading(WebView view, String url)433 public boolean shouldOverrideUrlLoading(WebView view, String url) { 434 if (url.startsWith("tel:")) { 435 startActivity(new Intent(Intent.ACTION_DIAL, Uri.parse(url))); 436 return true; 437 } 438 return false; 439 } 440 } 441 442 private class MyWebChromeClient extends WebChromeClient { 443 @Override onProgressChanged(WebView view, int newProgress)444 public void onProgressChanged(WebView view, int newProgress) { 445 final ProgressBar myProgressBar = findViewById(R.id.progress_bar); 446 myProgressBar.setProgress(newProgress); 447 } 448 } 449 runOnUiThreadIfNotFinishing(Runnable r)450 private void runOnUiThreadIfNotFinishing(Runnable r) { 451 if (!isFinishing()) { 452 runOnUiThread(r); 453 } 454 } 455 456 /** 457 * This alias presents the target activity, CaptivePortalLoginActivity, as a independent 458 * entity with its own intent filter to handle URL links. This alias will be enabled/disabled 459 * dynamically to handle url links based on the network conditions. 460 */ getAlias(Context context)461 public static String getAlias(Context context) { 462 try { 463 PackageInfo p = context.getPackageManager().getPackageInfo(context.getPackageName(), 464 PackageManager.GET_ACTIVITIES | PackageManager.MATCH_DISABLED_COMPONENTS); 465 for (ActivityInfo activityInfo : p.activities) { 466 String targetActivity = activityInfo.targetActivity; 467 if (CaptivePortalLoginActivity.class.getName().equals(targetActivity)) { 468 return activityInfo.name; 469 } 470 } 471 } catch (PackageManager.NameNotFoundException e) { 472 e.printStackTrace(); 473 } 474 return null; 475 } 476 logd(String s)477 private static void logd(String s) { 478 Log.d(TAG, s); 479 } 480 loge(String s)481 private static void loge(String s) { 482 Log.d(TAG, s); 483 } 484 485 } 486