CaptivePortalLoginActivity.java revision c457a04d937b0f3b81fb49b78f4cde46059e1ac9
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
17package com.android.carrierdefaultapp;
18
19import android.app.Activity;
20import android.app.LoadedApk;
21import android.content.Context;
22import android.content.Intent;
23import android.graphics.Bitmap;
24import android.net.ConnectivityManager;
25import android.net.ConnectivityManager.NetworkCallback;
26import android.net.Network;
27import android.net.NetworkCapabilities;
28import android.net.NetworkRequest;
29import android.net.Proxy;
30import android.net.TrafficStats;
31import android.net.Uri;
32import android.net.http.SslError;
33import android.os.Bundle;
34import android.telephony.CarrierConfigManager;
35import android.telephony.Rlog;
36import android.telephony.SubscriptionManager;
37import android.util.ArrayMap;
38import android.util.Log;
39import android.util.TypedValue;
40import android.webkit.SslErrorHandler;
41import android.webkit.WebChromeClient;
42import android.webkit.WebSettings;
43import android.webkit.WebView;
44import android.webkit.WebViewClient;
45import android.widget.ProgressBar;
46import android.widget.TextView;
47
48import com.android.internal.telephony.PhoneConstants;
49import com.android.internal.telephony.TelephonyIntents;
50import com.android.internal.util.ArrayUtils;
51
52import java.io.IOException;
53import java.lang.reflect.Field;
54import java.lang.reflect.Method;
55import java.net.HttpURLConnection;
56import java.net.MalformedURLException;
57import java.net.URL;
58import 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 */
66public 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(
230                            new URL(mCm.getCaptivePortalServerUrl()));
231                    urlConnection.setInstanceFollowRedirects(false);
232                    urlConnection.setConnectTimeout(SOCKET_TIMEOUT_MS);
233                    urlConnection.setReadTimeout(SOCKET_TIMEOUT_MS);
234                    urlConnection.setUseCaches(false);
235                    urlConnection.getInputStream();
236                    httpResponseCode = urlConnection.getResponseCode();
237                } catch (IOException e) {
238                    loge(e.getMessage());
239                } finally {
240                    if (urlConnection != null) urlConnection.disconnect();
241                    TrafficStats.setThreadStatsTag(oldTag);
242                }
243                if (httpResponseCode == 204) {
244                    done(true);
245                }
246            }
247        }).start();
248    }
249
250    private Network getNetworkForCaptivePortal() {
251        Network[] info = mCm.getAllNetworks();
252        if (!ArrayUtils.isEmpty(info)) {
253            for (Network nw : info) {
254                final NetworkCapabilities nc = mCm.getNetworkCapabilities(nw);
255                if (nc.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
256                        && nc.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) {
257                    return nw;
258                }
259            }
260        }
261        return null;
262    }
263
264    private void requestNetworkForCaptivePortal() {
265        NetworkRequest request = new NetworkRequest.Builder()
266                .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
267                .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
268                .removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
269                .build();
270
271        mNetworkCallback = new ConnectivityManager.NetworkCallback() {
272            @Override
273            public void onAvailable(Network network) {
274                if (DBG) logd("Network available: " + network);
275                mCm.bindProcessToNetwork(network);
276                mNetwork = network;
277                runOnUiThreadIfNotFinishing(() -> {
278                    // Start initial page load so WebView finishes loading proxy settings.
279                    // Actual load of mUrl is initiated by MyWebViewClient.
280                    mWebView.loadData("", "text/html", null);
281                });
282            }
283
284            @Override
285            public void onUnavailable() {
286                if (DBG) logd("Network unavailable");
287                runOnUiThreadIfNotFinishing(() -> {
288                    // Instead of not loading anything in webview, simply load the page and return
289                    // HTTP error page in the absence of network connection.
290                    mWebView.loadUrl(mUrl.toString());
291                });
292            }
293        };
294        logd("request Network for captive portal");
295        mCm.requestNetwork(request, mNetworkCallback, NETWORK_REQUEST_TIMEOUT_MS);
296    }
297
298    private void releaseNetworkRequest() {
299        logd("release Network for captive portal");
300        if (mNetworkCallback != null) {
301            mCm.unregisterNetworkCallback(mNetworkCallback);
302            mNetworkCallback = null;
303            mNetwork = null;
304        }
305    }
306
307    private class MyWebViewClient extends WebViewClient {
308        private static final String INTERNAL_ASSETS = "file:///android_asset/";
309        private final String mBrowserBailOutToken = Long.toString(new Random().nextLong());
310        // How many Android device-independent-pixels per scaled-pixel
311        // dp/sp = (px/sp) / (px/dp) = (1/sp) / (1/dp)
312        private final float mDpPerSp = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 1,
313                    getResources().getDisplayMetrics())
314                / TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1,
315                    getResources().getDisplayMetrics());
316        private int mPagesLoaded;
317
318        // If we haven't finished cleaning up the history, don't allow going back.
319        public boolean allowBack() {
320            return mPagesLoaded > 1;
321        }
322
323        @Override
324        public void onPageStarted(WebView view, String url, Bitmap favicon) {
325            if (url.contains(mBrowserBailOutToken)) {
326                mLaunchBrowser = true;
327                done(false);
328                return;
329            }
330            // The first page load is used only to cause the WebView to
331            // fetch the proxy settings.  Don't update the URL bar, and
332            // don't check if the captive portal is still there.
333            if (mPagesLoaded == 0) return;
334            // For internally generated pages, leave URL bar listing prior URL as this is the URL
335            // the page refers to.
336            if (!url.startsWith(INTERNAL_ASSETS)) {
337                final TextView myUrlBar = findViewById(R.id.url_bar);
338                myUrlBar.setText(url);
339            }
340            if (mNetwork != null) {
341                testForCaptivePortal();
342            }
343        }
344
345        @Override
346        public void onPageFinished(WebView view, String url) {
347            mPagesLoaded++;
348            if (mPagesLoaded == 1) {
349                // Now that WebView has loaded at least one page we know it has read in the proxy
350                // settings.  Now prompt the WebView read the Network-specific proxy settings.
351                setWebViewProxy();
352                // Load the real page.
353                view.loadUrl(mUrl.toString());
354                return;
355            } else if (mPagesLoaded == 2) {
356                // Prevent going back to empty first page.
357                view.clearHistory();
358            }
359            if (mNetwork != null) {
360                testForCaptivePortal();
361            }
362        }
363
364        // Convert Android device-independent-pixels (dp) to HTML size.
365        private String dp(int dp) {
366            // HTML px's are scaled just like dp's, so just add "px" suffix.
367            return Integer.toString(dp) + "px";
368        }
369
370        // Convert Android scaled-pixels (sp) to HTML size.
371        private String sp(int sp) {
372            // Convert sp to dp's.
373            float dp = sp * mDpPerSp;
374            // Apply a scale factor to make things look right.
375            dp *= 1.3;
376            // Convert dp's to HTML size.
377            return dp((int) dp);
378        }
379
380        // A web page consisting of a large broken lock icon to indicate SSL failure.
381        private final String SSL_ERROR_HTML = "<html><head><style>"
382                + "body { margin-left:" + dp(48) + "; margin-right:" + dp(48) + "; "
383                + "margin-top:" + dp(96) + "; background-color:#fafafa; }"
384                + "img { width:" + dp(48) + "; height:" + dp(48) + "; }"
385                + "div.warn { font-size:" + sp(16) + "; margin-top:" + dp(16) + "; "
386                + "           opacity:0.87; line-height:1.28; }"
387                + "div.example { font-size:" + sp(14) + "; margin-top:" + dp(16) + "; "
388                + "              opacity:0.54; line-height:1.21905; }"
389                + "a { font-size:" + sp(14) + "; text-decoration:none; text-transform:uppercase; "
390                + "    margin-top:" + dp(24) + "; display:inline-block; color:#4285F4; "
391                + "    height:" + dp(48) + "; font-weight:bold; }"
392                + "</style></head><body><p><img src=quantum_ic_warning_amber_96.png><br>"
393                + "<div class=warn>%s</div>"
394                + "<div class=example>%s</div>" + "<a href=%s>%s</a></body></html>";
395
396        @Override
397        public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) {
398            Log.w(TAG, "SSL error (error: " + error.getPrimaryError() + " host: "
399                    // Only show host to avoid leaking private info.
400                    + Uri.parse(error.getUrl()).getHost() + " certificate: "
401                    + error.getCertificate() + "); displaying SSL warning.");
402            final String html = String.format(SSL_ERROR_HTML, getString(R.string.ssl_error_warning),
403                    getString(R.string.ssl_error_example), mBrowserBailOutToken,
404                    getString(R.string.ssl_error_continue));
405            view.loadDataWithBaseURL(INTERNAL_ASSETS, html, "text/HTML", "UTF-8", null);
406        }
407
408        @Override
409        public boolean shouldOverrideUrlLoading(WebView view, String url) {
410            if (url.startsWith("tel:")) {
411                startActivity(new Intent(Intent.ACTION_DIAL, Uri.parse(url)));
412                return true;
413            }
414            return false;
415        }
416    }
417
418    private class MyWebChromeClient extends WebChromeClient {
419        @Override
420        public void onProgressChanged(WebView view, int newProgress) {
421            final ProgressBar myProgressBar = findViewById(R.id.progress_bar);
422            myProgressBar.setProgress(newProgress);
423        }
424    }
425
426    private void runOnUiThreadIfNotFinishing(Runnable r) {
427        if (!isFinishing()) {
428            runOnUiThread(r);
429        }
430    }
431
432    private static void logd(String s) {
433        Rlog.d(TAG, s);
434    }
435
436    private static void loge(String s) {
437        Rlog.d(TAG, s);
438    }
439
440}
441