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