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