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.content.pm.ActivityInfo;
24 import android.content.pm.PackageInfo;
25 import android.content.pm.PackageManager;
26 import android.graphics.Bitmap;
27 import android.net.ConnectivityManager;
28 import android.net.ConnectivityManager.NetworkCallback;
29 import android.net.Network;
30 import android.net.NetworkCapabilities;
31 import android.net.NetworkRequest;
32 import android.net.Proxy;
33 import android.net.TrafficStats;
34 import android.net.Uri;
35 import android.net.dns.ResolvUtil;
36 import android.net.http.SslError;
37 import android.os.Bundle;
38 import android.telephony.CarrierConfigManager;
39 import android.telephony.Rlog;
40 import android.telephony.SubscriptionManager;
41 import android.text.TextUtils;
42 import android.util.ArrayMap;
43 import android.util.Log;
44 import android.util.TypedValue;
45 import android.webkit.SslErrorHandler;
46 import android.webkit.WebChromeClient;
47 import android.webkit.WebSettings;
48 import android.webkit.WebView;
49 import android.webkit.WebViewClient;
50 import android.widget.ProgressBar;
51 import android.widget.TextView;
52 
53 import com.android.internal.telephony.PhoneConstants;
54 import com.android.internal.telephony.TelephonyIntents;
55 import com.android.internal.util.ArrayUtils;
56 
57 import java.io.IOException;
58 import java.lang.reflect.Field;
59 import java.lang.reflect.Method;
60 import java.net.HttpURLConnection;
61 import java.net.MalformedURLException;
62 import java.net.URL;
63 import 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  */
71 public 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
onCreate(Bundle savedInstanceState)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
onBackPressed()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
onDestroy()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 
setNetwork(Network network)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.
setWebViewProxy()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 
done(boolean success)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 
getUrlForCaptivePortal()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 
testForCaptivePortal()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 
getNetworkForCaptivePortal()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 
requestNetworkForCaptivePortal()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 
releaseNetworkRequest()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.
allowBack()356         public boolean allowBack() {
357             return mPagesLoaded > 1;
358         }
359 
360         @Override
onPageStarted(WebView view, String url, Bitmap favicon)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
onPageFinished(WebView view, String url)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.
dp(int dp)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.
sp(int sp)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
onReceivedSslError(WebView view, SslErrorHandler handler, SslError error)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
shouldOverrideUrlLoading(WebView view, String url)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
onProgressChanged(WebView view, int newProgress)457         public void onProgressChanged(WebView view, int newProgress) {
458             final ProgressBar myProgressBar = findViewById(R.id.progress_bar);
459             myProgressBar.setProgress(newProgress);
460         }
461     }
462 
runOnUiThreadIfNotFinishing(Runnable r)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      */
getAlias(Context context)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 
logd(String s)490     private static void logd(String s) {
491         Rlog.d(TAG, s);
492     }
493 
loge(String s)494     private static void loge(String s) {
495         Rlog.d(TAG, s);
496     }
497 
498 }
499