Home | History | Annotate | Download | only in captiveportallogin
      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 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.CaptivePortal;
     25 import android.net.ConnectivityManager;
     26 import android.net.ConnectivityManager.NetworkCallback;
     27 import android.net.Network;
     28 import android.net.NetworkCapabilities;
     29 import android.net.NetworkInfo;
     30 import android.net.NetworkRequest;
     31 import android.net.Proxy;
     32 import android.net.Uri;
     33 import android.net.http.SslError;
     34 import android.os.Build;
     35 import android.os.Bundle;
     36 import android.provider.Settings;
     37 import android.util.ArrayMap;
     38 import android.util.Log;
     39 import android.util.TypedValue;
     40 import android.view.Menu;
     41 import android.view.MenuItem;
     42 import android.view.View;
     43 import android.webkit.SslErrorHandler;
     44 import android.webkit.WebChromeClient;
     45 import android.webkit.WebSettings;
     46 import android.webkit.WebView;
     47 import android.webkit.WebViewClient;
     48 import android.widget.ProgressBar;
     49 import android.widget.TextView;
     50 
     51 import com.android.internal.logging.MetricsLogger;
     52 import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
     53 
     54 import java.io.IOException;
     55 import java.net.HttpURLConnection;
     56 import java.net.MalformedURLException;
     57 import java.net.URL;
     58 import java.lang.InterruptedException;
     59 import java.lang.reflect.Field;
     60 import java.lang.reflect.Method;
     61 import java.util.Objects;
     62 import java.util.Random;
     63 import java.util.concurrent.atomic.AtomicBoolean;
     64 
     65 public class CaptivePortalLoginActivity extends Activity {
     66     private static final String TAG = CaptivePortalLoginActivity.class.getSimpleName();
     67     private static final boolean DBG = true;
     68     private static final boolean VDBG = false;
     69 
     70     private static final int SOCKET_TIMEOUT_MS = 10000;
     71 
     72     private enum Result {
     73         DISMISSED(MetricsEvent.ACTION_CAPTIVE_PORTAL_LOGIN_RESULT_DISMISSED),
     74         UNWANTED(MetricsEvent.ACTION_CAPTIVE_PORTAL_LOGIN_RESULT_UNWANTED),
     75         WANTED_AS_IS(MetricsEvent.ACTION_CAPTIVE_PORTAL_LOGIN_RESULT_WANTED_AS_IS);
     76 
     77         final int metricsEvent;
     78         Result(int metricsEvent) { this.metricsEvent = metricsEvent; }
     79     };
     80 
     81     private URL mUrl;
     82     private String mUserAgent;
     83     private Network mNetwork;
     84     private CaptivePortal mCaptivePortal;
     85     private NetworkCallback mNetworkCallback;
     86     private ConnectivityManager mCm;
     87     private boolean mLaunchBrowser = false;
     88     private MyWebViewClient mWebViewClient;
     89     // Ensures that done() happens once exactly, handling concurrent callers with atomic operations.
     90     private final AtomicBoolean isDone = new AtomicBoolean(false);
     91 
     92     @Override
     93     protected void onCreate(Bundle savedInstanceState) {
     94         super.onCreate(savedInstanceState);
     95 
     96         logMetricsEvent(MetricsEvent.ACTION_CAPTIVE_PORTAL_LOGIN_ACTIVITY);
     97 
     98         mCm = ConnectivityManager.from(this);
     99         mNetwork = getIntent().getParcelableExtra(ConnectivityManager.EXTRA_NETWORK);
    100         mCaptivePortal = getIntent().getParcelableExtra(ConnectivityManager.EXTRA_CAPTIVE_PORTAL);
    101         mUserAgent =
    102                 getIntent().getStringExtra(ConnectivityManager.EXTRA_CAPTIVE_PORTAL_USER_AGENT);
    103         mUrl = getUrl();
    104         if (mUrl == null) {
    105             // getUrl() failed to parse the url provided in the intent: bail out in a way that
    106             // at least provides network access.
    107             done(Result.WANTED_AS_IS);
    108             return;
    109         }
    110         if (DBG) {
    111             Log.d(TAG, String.format("onCreate for %s", mUrl.toString()));
    112         }
    113 
    114         // Also initializes proxy system properties.
    115         mCm.bindProcessToNetwork(mNetwork);
    116 
    117         // Proxy system properties must be initialized before setContentView is called because
    118         // setContentView initializes the WebView logic which in turn reads the system properties.
    119         setContentView(R.layout.activity_captive_portal_login);
    120 
    121         // Exit app if Network disappears.
    122         final NetworkCapabilities networkCapabilities = mCm.getNetworkCapabilities(mNetwork);
    123         if (networkCapabilities == null) {
    124             finishAndRemoveTask();
    125             return;
    126         }
    127         mNetworkCallback = new NetworkCallback() {
    128             @Override
    129             public void onLost(Network lostNetwork) {
    130                 if (mNetwork.equals(lostNetwork)) done(Result.UNWANTED);
    131             }
    132         };
    133         final NetworkRequest.Builder builder = new NetworkRequest.Builder();
    134         for (int transportType : networkCapabilities.getTransportTypes()) {
    135             builder.addTransportType(transportType);
    136         }
    137         mCm.registerNetworkCallback(builder.build(), mNetworkCallback);
    138 
    139         getActionBar().setDisplayShowHomeEnabled(false);
    140         getActionBar().setElevation(0); // remove shadow
    141         getActionBar().setTitle(getHeaderTitle());
    142         getActionBar().setSubtitle("");
    143 
    144         final WebView webview = getWebview();
    145         webview.clearCache(true);
    146         WebSettings webSettings = webview.getSettings();
    147         webSettings.setJavaScriptEnabled(true);
    148         webSettings.setMixedContentMode(WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE);
    149         webSettings.setUseWideViewPort(true);
    150         webSettings.setLoadWithOverviewMode(true);
    151         webSettings.setSupportZoom(true);
    152         webSettings.setBuiltInZoomControls(true);
    153         webSettings.setDisplayZoomControls(false);
    154         mWebViewClient = new MyWebViewClient();
    155         webview.setWebViewClient(mWebViewClient);
    156         webview.setWebChromeClient(new MyWebChromeClient());
    157         // Start initial page load so WebView finishes loading proxy settings.
    158         // Actual load of mUrl is initiated by MyWebViewClient.
    159         webview.loadData("", "text/html", null);
    160     }
    161 
    162     // Find WebView's proxy BroadcastReceiver and prompt it to read proxy system properties.
    163     private void setWebViewProxy() {
    164         LoadedApk loadedApk = getApplication().mLoadedApk;
    165         try {
    166             Field receiversField = LoadedApk.class.getDeclaredField("mReceivers");
    167             receiversField.setAccessible(true);
    168             ArrayMap receivers = (ArrayMap) receiversField.get(loadedApk);
    169             for (Object receiverMap : receivers.values()) {
    170                 for (Object rec : ((ArrayMap) receiverMap).keySet()) {
    171                     Class clazz = rec.getClass();
    172                     if (clazz.getName().contains("ProxyChangeListener")) {
    173                         Method onReceiveMethod = clazz.getDeclaredMethod("onReceive", Context.class,
    174                                 Intent.class);
    175                         Intent intent = new Intent(Proxy.PROXY_CHANGE_ACTION);
    176                         onReceiveMethod.invoke(rec, getApplicationContext(), intent);
    177                         Log.v(TAG, "Prompting WebView proxy reload.");
    178                     }
    179                 }
    180             }
    181         } catch (Exception e) {
    182             Log.e(TAG, "Exception while setting WebView proxy: " + e);
    183         }
    184     }
    185 
    186     private void done(Result result) {
    187         if (isDone.getAndSet(true)) {
    188             // isDone was already true: done() already called
    189             return;
    190         }
    191         if (DBG) {
    192             Log.d(TAG, String.format("Result %s for %s", result.name(), mUrl.toString()));
    193         }
    194         logMetricsEvent(result.metricsEvent);
    195         switch (result) {
    196             case DISMISSED:
    197                 mCaptivePortal.reportCaptivePortalDismissed();
    198                 break;
    199             case UNWANTED:
    200                 mCaptivePortal.ignoreNetwork();
    201                 break;
    202             case WANTED_AS_IS:
    203                 mCaptivePortal.useNetwork();
    204                 break;
    205         }
    206         finishAndRemoveTask();
    207     }
    208 
    209     @Override
    210     public boolean onCreateOptionsMenu(Menu menu) {
    211         getMenuInflater().inflate(R.menu.captive_portal_login, menu);
    212         return true;
    213     }
    214 
    215     @Override
    216     public void onBackPressed() {
    217         WebView myWebView = findViewById(R.id.webview);
    218         if (myWebView.canGoBack() && mWebViewClient.allowBack()) {
    219             myWebView.goBack();
    220         } else {
    221             super.onBackPressed();
    222         }
    223     }
    224 
    225     @Override
    226     public boolean onOptionsItemSelected(MenuItem item) {
    227         final Result result;
    228         final String action;
    229         final int id = item.getItemId();
    230         switch (id) {
    231             case R.id.action_use_network:
    232                 result = Result.WANTED_AS_IS;
    233                 action = "USE_NETWORK";
    234                 break;
    235             case R.id.action_do_not_use_network:
    236                 result = Result.UNWANTED;
    237                 action = "DO_NOT_USE_NETWORK";
    238                 break;
    239             default:
    240                 return super.onOptionsItemSelected(item);
    241         }
    242         if (DBG) {
    243             Log.d(TAG, String.format("onOptionsItemSelect %s for %s", action, mUrl.toString()));
    244         }
    245         done(result);
    246         return true;
    247     }
    248 
    249     @Override
    250     public void onDestroy() {
    251         super.onDestroy();
    252         if (mNetworkCallback != null) {
    253             // mNetworkCallback is not null if mUrl is not null.
    254             mCm.unregisterNetworkCallback(mNetworkCallback);
    255         }
    256         if (mLaunchBrowser) {
    257             // Give time for this network to become default. After 500ms just proceed.
    258             for (int i = 0; i < 5; i++) {
    259                 // TODO: This misses when mNetwork underlies a VPN.
    260                 if (mNetwork.equals(mCm.getActiveNetwork())) break;
    261                 try {
    262                     Thread.sleep(100);
    263                 } catch (InterruptedException e) {
    264                 }
    265             }
    266             final String url = mUrl.toString();
    267             if (DBG) {
    268                 Log.d(TAG, "starting activity with intent ACTION_VIEW for " + url);
    269             }
    270             startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url)));
    271         }
    272     }
    273 
    274     private URL getUrl() {
    275         String url = getIntent().getStringExtra(ConnectivityManager.EXTRA_CAPTIVE_PORTAL_URL);
    276         if (url == null) {
    277             url = mCm.getCaptivePortalServerUrl();
    278         }
    279         return makeURL(url);
    280     }
    281 
    282     private static URL makeURL(String url) {
    283         try {
    284             return new URL(url);
    285         } catch (MalformedURLException e) {
    286             Log.e(TAG, "Invalid URL " + url);
    287         }
    288         return null;
    289     }
    290 
    291     private static String host(URL url) {
    292         if (url == null) {
    293             return null;
    294         }
    295         return url.getHost();
    296     }
    297 
    298     private static String sanitizeURL(URL url) {
    299         // In non-Debug build, only show host to avoid leaking private info.
    300         return Build.IS_DEBUGGABLE ? Objects.toString(url) : host(url);
    301     }
    302 
    303     private void testForCaptivePortal() {
    304         // TODO: reuse NetworkMonitor facilities for consistent captive portal detection.
    305         new Thread(new Runnable() {
    306             public void run() {
    307                 // Give time for captive portal to open.
    308                 try {
    309                     Thread.sleep(1000);
    310                 } catch (InterruptedException e) {
    311                 }
    312                 HttpURLConnection urlConnection = null;
    313                 int httpResponseCode = 500;
    314                 try {
    315                     urlConnection = (HttpURLConnection) mNetwork.openConnection(mUrl);
    316                     urlConnection.setInstanceFollowRedirects(false);
    317                     urlConnection.setConnectTimeout(SOCKET_TIMEOUT_MS);
    318                     urlConnection.setReadTimeout(SOCKET_TIMEOUT_MS);
    319                     urlConnection.setUseCaches(false);
    320                     if (mUserAgent != null) {
    321                        urlConnection.setRequestProperty("User-Agent", mUserAgent);
    322                     }
    323                     // cannot read request header after connection
    324                     String requestHeader = urlConnection.getRequestProperties().toString();
    325 
    326                     urlConnection.getInputStream();
    327                     httpResponseCode = urlConnection.getResponseCode();
    328                     if (DBG) {
    329                         Log.d(TAG, "probe at " + mUrl +
    330                                 " ret=" + httpResponseCode +
    331                                 " request=" + requestHeader +
    332                                 " headers=" + urlConnection.getHeaderFields());
    333                     }
    334                 } catch (IOException e) {
    335                 } finally {
    336                     if (urlConnection != null) urlConnection.disconnect();
    337                 }
    338                 if (httpResponseCode == 204) {
    339                     done(Result.DISMISSED);
    340                 }
    341             }
    342         }).start();
    343     }
    344 
    345     private class MyWebViewClient extends WebViewClient {
    346         private static final String INTERNAL_ASSETS = "file:///android_asset/";
    347 
    348         private final String mBrowserBailOutToken = Long.toString(new Random().nextLong());
    349         // How many Android device-independent-pixels per scaled-pixel
    350         // dp/sp = (px/sp) / (px/dp) = (1/sp) / (1/dp)
    351         private final float mDpPerSp = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 1,
    352                     getResources().getDisplayMetrics()) /
    353                     TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1,
    354                     getResources().getDisplayMetrics());
    355         private int mPagesLoaded;
    356         // the host of the page that this webview is currently loading. Can be null when undefined.
    357         private String mHostname;
    358 
    359         // If we haven't finished cleaning up the history, don't allow going back.
    360         public boolean allowBack() {
    361             return mPagesLoaded > 1;
    362         }
    363 
    364         @Override
    365         public void onPageStarted(WebView view, String urlString, Bitmap favicon) {
    366             if (urlString.contains(mBrowserBailOutToken)) {
    367                 mLaunchBrowser = true;
    368                 done(Result.WANTED_AS_IS);
    369                 return;
    370             }
    371             // The first page load is used only to cause the WebView to
    372             // fetch the proxy settings.  Don't update the URL bar, and
    373             // don't check if the captive portal is still there.
    374             if (mPagesLoaded == 0) {
    375                 return;
    376             }
    377             final URL url = makeURL(urlString);
    378             Log.d(TAG, "onPageSarted: " + sanitizeURL(url));
    379             mHostname = host(url);
    380             // For internally generated pages, leave URL bar listing prior URL as this is the URL
    381             // the page refers to.
    382             if (!urlString.startsWith(INTERNAL_ASSETS)) {
    383                 String subtitle = (url != null) ? getHeaderSubtitle(url) : urlString;
    384                 getActionBar().setSubtitle(subtitle);
    385             }
    386             getProgressBar().setVisibility(View.VISIBLE);
    387             testForCaptivePortal();
    388         }
    389 
    390         @Override
    391         public void onPageFinished(WebView view, String url) {
    392             mPagesLoaded++;
    393             getProgressBar().setVisibility(View.INVISIBLE);
    394             if (mPagesLoaded == 1) {
    395                 // Now that WebView has loaded at least one page we know it has read in the proxy
    396                 // settings.  Now prompt the WebView read the Network-specific proxy settings.
    397                 setWebViewProxy();
    398                 // Load the real page.
    399                 view.loadUrl(mUrl.toString());
    400                 return;
    401             } else if (mPagesLoaded == 2) {
    402                 // Prevent going back to empty first page.
    403                 // Fix for missing focus, see b/62449959 for details. Remove it once we get a
    404                 // newer version of WebView (60.x.y).
    405                 view.requestFocus();
    406                 view.clearHistory();
    407             }
    408             testForCaptivePortal();
    409         }
    410 
    411         // Convert Android scaled-pixels (sp) to HTML size.
    412         private String sp(int sp) {
    413             // Convert sp to dp's.
    414             float dp = sp * mDpPerSp;
    415             // Apply a scale factor to make things look right.
    416             dp *= 1.3;
    417             // Convert dp's to HTML size.
    418             // HTML px's are scaled just like dp's, so just add "px" suffix.
    419             return Integer.toString((int)dp) + "px";
    420         }
    421 
    422         // A web page consisting of a large broken lock icon to indicate SSL failure.
    423 
    424         @Override
    425         public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) {
    426             final URL url = makeURL(error.getUrl());
    427             final String host = host(url);
    428             Log.d(TAG, String.format("SSL error: %s, url: %s, certificate: %s",
    429                     error.getPrimaryError(), sanitizeURL(url), error.getCertificate()));
    430             if (url == null || !Objects.equals(host, mHostname)) {
    431                 // Ignore ssl errors for resources coming from a different hostname than the page
    432                 // that we are currently loading, and only cancel the request.
    433                 handler.cancel();
    434                 return;
    435             }
    436             logMetricsEvent(MetricsEvent.CAPTIVE_PORTAL_LOGIN_ACTIVITY_SSL_ERROR);
    437             final String sslErrorPage = makeSslErrorPage();
    438             view.loadDataWithBaseURL(INTERNAL_ASSETS, sslErrorPage, "text/HTML", "UTF-8", null);
    439         }
    440 
    441         private String makeSslErrorPage() {
    442             final String warningMsg = getString(R.string.ssl_error_warning);
    443             final String exampleMsg = getString(R.string.ssl_error_example);
    444             final String continueMsg = getString(R.string.ssl_error_continue);
    445             return String.join("\n",
    446                     "<html>",
    447                     "<head>",
    448                     "  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
    449                     "  <style>",
    450                     "    body {",
    451                     "      background-color:#fafafa;",
    452                     "      margin:auto;",
    453                     "      width:80%;",
    454                     "      margin-top: 96px",
    455                     "    }",
    456                     "    img {",
    457                     "      height:48px;",
    458                     "      width:48px;",
    459                     "    }",
    460                     "    div.warn {",
    461                     "      font-size:" + sp(16) + ";",
    462                     "      line-height:1.28;",
    463                     "      margin-top:16px;",
    464                     "      opacity:0.87;",
    465                     "    }",
    466                     "    div.example {",
    467                     "      font-size:" + sp(14) + ";",
    468                     "      line-height:1.21905;",
    469                     "      margin-top:16px;",
    470                     "      opacity:0.54;",
    471                     "    }",
    472                     "    a {",
    473                     "      color:#4285F4;",
    474                     "      display:inline-block;",
    475                     "      font-size:" + sp(14) + ";",
    476                     "      font-weight:bold;",
    477                     "      height:48px;",
    478                     "      margin-top:24px;",
    479                     "      text-decoration:none;",
    480                     "      text-transform:uppercase;",
    481                     "    }",
    482                     "  </style>",
    483                     "</head>",
    484                     "<body>",
    485                     "  <p><img src=quantum_ic_warning_amber_96.png><br>",
    486                     "  <div class=warn>" + warningMsg + "</div>",
    487                     "  <div class=example>" + exampleMsg + "</div>",
    488                     "  <a href=" + mBrowserBailOutToken + ">" + continueMsg + "</a>",
    489                     "</body>",
    490                     "</html>");
    491         }
    492 
    493         @Override
    494         public boolean shouldOverrideUrlLoading (WebView view, String url) {
    495             if (url.startsWith("tel:")) {
    496                 startActivity(new Intent(Intent.ACTION_DIAL, Uri.parse(url)));
    497                 return true;
    498             }
    499             return false;
    500         }
    501     }
    502 
    503     private class MyWebChromeClient extends WebChromeClient {
    504         @Override
    505         public void onProgressChanged(WebView view, int newProgress) {
    506             getProgressBar().setProgress(newProgress);
    507         }
    508     }
    509 
    510     private ProgressBar getProgressBar() {
    511         return findViewById(R.id.progress_bar);
    512     }
    513 
    514     private WebView getWebview() {
    515         return findViewById(R.id.webview);
    516     }
    517 
    518     private String getHeaderTitle() {
    519         NetworkInfo info = mCm.getNetworkInfo(mNetwork);
    520         if (info == null) {
    521             return getString(R.string.action_bar_label);
    522         }
    523         NetworkCapabilities nc = mCm.getNetworkCapabilities(mNetwork);
    524         if (!nc.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
    525             return getString(R.string.action_bar_label);
    526         }
    527         return getString(R.string.action_bar_title, info.getExtraInfo().replaceAll("^\"|\"$", ""));
    528     }
    529 
    530     private String getHeaderSubtitle(URL url) {
    531         String host = host(url);
    532         final String https = "https";
    533         if (https.equals(url.getProtocol())) {
    534             return https + "://" + host;
    535         }
    536         return host;
    537     }
    538 
    539     private void logMetricsEvent(int event) {
    540         MetricsLogger.action(this, event, getPackageName());
    541     }
    542 }
    543