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