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