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