1 // Copyright 2015 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
4 
5 package org.chromium.webview_shell;
6 
7 import android.Manifest;
8 import android.app.Activity;
9 import android.app.AlertDialog;
10 import android.content.ActivityNotFoundException;
11 import android.content.Context;
12 import android.content.Intent;
13 import android.content.IntentFilter;
14 import android.content.pm.PackageManager;
15 import android.content.pm.ResolveInfo;
16 import android.graphics.Bitmap;
17 import android.graphics.Color;
18 import android.net.Uri;
19 import android.os.Build;
20 import android.os.Bundle;
21 import android.provider.Browser;
22 import android.util.Log;
23 import android.util.SparseArray;
24 
25 import android.view.KeyEvent;
26 import android.view.MenuItem;
27 import android.view.View;
28 import android.view.View.OnKeyListener;
29 import android.view.ViewGroup;
30 import android.view.ViewGroup.LayoutParams;
31 import android.view.inputmethod.InputMethodManager;
32 
33 import android.webkit.GeolocationPermissions;
34 import android.webkit.PermissionRequest;
35 import android.webkit.WebChromeClient;
36 import android.webkit.WebResourceRequest;
37 import android.webkit.WebSettings;
38 import android.webkit.WebView;
39 import android.webkit.WebViewClient;
40 
41 import android.widget.EditText;
42 import android.widget.PopupMenu;
43 import android.widget.TextView;
44 
45 import java.lang.reflect.InvocationTargetException;
46 import java.lang.reflect.Method;
47 
48 import java.net.URI;
49 import java.net.URISyntaxException;
50 
51 import java.util.ArrayList;
52 import java.util.HashMap;
53 import java.util.List;
54 import java.util.regex.Matcher;
55 import java.util.regex.Pattern;
56 
57 /**
58  * This activity is designed for starting a "mini-browser" for manual testing of WebView.
59  * It takes an optional URL as an argument, and displays the page. There is a URL bar
60  * on top of the webview for manually specifying URLs to load.
61  */
62 public class WebViewBrowserActivity extends Activity implements PopupMenu.OnMenuItemClickListener {
63     private static final String TAG = "WebViewShell";
64 
65     // Our imaginary Android permission to associate with the WebKit geo permission
66     private static final String RESOURCE_GEO = "RESOURCE_GEO";
67     // Our imaginary WebKit permission to request when loading a file:// URL
68     private static final String RESOURCE_FILE_URL = "RESOURCE_FILE_URL";
69     // WebKit permissions with no corresponding Android permission can always be granted
70     private static final String NO_ANDROID_PERMISSION = "NO_ANDROID_PERMISSION";
71 
72     // Map from WebKit permissions to Android permissions
73     private static final HashMap<String, String> sPermissions;
74     static {
75         sPermissions = new HashMap<String, String>();
sPermissions.put(RESOURCE_GEO, Manifest.permission.ACCESS_FINE_LOCATION)76         sPermissions.put(RESOURCE_GEO, Manifest.permission.ACCESS_FINE_LOCATION);
sPermissions.put(RESOURCE_FILE_URL, Manifest.permission.READ_EXTERNAL_STORAGE)77         sPermissions.put(RESOURCE_FILE_URL, Manifest.permission.READ_EXTERNAL_STORAGE);
sPermissions.put(PermissionRequest.RESOURCE_AUDIO_CAPTURE, Manifest.permission.RECORD_AUDIO)78         sPermissions.put(PermissionRequest.RESOURCE_AUDIO_CAPTURE,
79                 Manifest.permission.RECORD_AUDIO);
sPermissions.put(PermissionRequest.RESOURCE_MIDI_SYSEX, NO_ANDROID_PERMISSION)80         sPermissions.put(PermissionRequest.RESOURCE_MIDI_SYSEX, NO_ANDROID_PERMISSION);
sPermissions.put(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID, NO_ANDROID_PERMISSION)81         sPermissions.put(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID, NO_ANDROID_PERMISSION);
sPermissions.put(PermissionRequest.RESOURCE_VIDEO_CAPTURE, Manifest.permission.CAMERA)82         sPermissions.put(PermissionRequest.RESOURCE_VIDEO_CAPTURE,
83                 Manifest.permission.CAMERA);
84     }
85 
86     private static final Pattern WEBVIEW_VERSION_PATTERN =
87             Pattern.compile("(Chrome/)([\\d\\.]+)\\s");
88 
89     private EditText mUrlBar;
90     private WebView mWebView;
91     private String mWebViewVersion;
92 
93     // Each time we make a request, store it here with an int key. onRequestPermissionsResult will
94     // look up the request in order to grant the approprate permissions.
95     private SparseArray<PermissionRequest> mPendingRequests = new SparseArray<PermissionRequest>();
96     private int mNextRequestKey = 0;
97 
98     // Work around our wonky API by wrapping a geo permission prompt inside a regular
99     // PermissionRequest.
100     private static class GeoPermissionRequest extends PermissionRequest {
101         private String mOrigin;
102         private GeolocationPermissions.Callback mCallback;
103 
GeoPermissionRequest(String origin, GeolocationPermissions.Callback callback)104         public GeoPermissionRequest(String origin, GeolocationPermissions.Callback callback) {
105             mOrigin = origin;
106             mCallback = callback;
107         }
108 
getOrigin()109         public Uri getOrigin() {
110             return Uri.parse(mOrigin);
111         }
112 
getResources()113         public String[] getResources() {
114             return new String[] { WebViewBrowserActivity.RESOURCE_GEO };
115         }
116 
grant(String[] resources)117         public void grant(String[] resources) {
118             assert resources.length == 1;
119             assert WebViewBrowserActivity.RESOURCE_GEO.equals(resources[0]);
120             mCallback.invoke(mOrigin, true, false);
121         }
122 
deny()123         public void deny() {
124             mCallback.invoke(mOrigin, false, false);
125         }
126     }
127 
128     // For simplicity, also treat the read access needed for file:// URLs as a regular
129     // PermissionRequest.
130     private class FilePermissionRequest extends PermissionRequest {
131         private String mOrigin;
132 
FilePermissionRequest(String origin)133         public FilePermissionRequest(String origin) {
134             mOrigin = origin;
135         }
136 
getOrigin()137         public Uri getOrigin() {
138             return Uri.parse(mOrigin);
139         }
140 
getResources()141         public String[] getResources() {
142             return new String[] { WebViewBrowserActivity.RESOURCE_FILE_URL };
143         }
144 
grant(String[] resources)145         public void grant(String[] resources) {
146             assert resources.length == 1;
147             assert WebViewBrowserActivity.RESOURCE_FILE_URL.equals(resources[0]);
148             // Try again now that we have read access.
149             WebViewBrowserActivity.this.mWebView.loadUrl(mOrigin);
150         }
151 
deny()152         public void deny() {
153             // womp womp
154         }
155     }
156 
157     @Override
onCreate(Bundle savedInstanceState)158     public void onCreate(Bundle savedInstanceState) {
159         super.onCreate(savedInstanceState);
160         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
161             WebView.setWebContentsDebuggingEnabled(true);
162         }
163         setContentView(R.layout.activity_webview_browser);
164         mUrlBar = (EditText) findViewById(R.id.url_field);
165         mUrlBar.setOnKeyListener(new OnKeyListener() {
166             public boolean onKey(View view, int keyCode, KeyEvent event) {
167                 if (keyCode == KeyEvent.KEYCODE_ENTER && event.getAction() == KeyEvent.ACTION_UP) {
168                     loadUrlFromUrlBar(view);
169                     return true;
170                 }
171                 return false;
172             }
173         });
174 
175         createAndInitializeWebView();
176 
177         String url = getUrlFromIntent(getIntent());
178         if (url != null) {
179             setUrlBarText(url);
180             setUrlFail(false);
181             loadUrlFromUrlBar(mUrlBar);
182         }
183     }
184 
getContainer()185     ViewGroup getContainer() {
186         return (ViewGroup) findViewById(R.id.container);
187     }
188 
createAndInitializeWebView()189     private void createAndInitializeWebView() {
190         WebView webview = new WebView(this);
191         WebSettings settings = webview.getSettings();
192         initializeSettings(settings);
193 
194         Matcher matcher = WEBVIEW_VERSION_PATTERN.matcher(settings.getUserAgentString());
195         if (matcher.find()) {
196             mWebViewVersion = matcher.group(2);
197         } else {
198             mWebViewVersion = "-";
199         }
200         setTitle(getResources().getString(R.string.title_activity_browser) + " " + mWebViewVersion);
201 
202         webview.setWebViewClient(new WebViewClient() {
203             @Override
204             public void onPageStarted(WebView view, String url, Bitmap favicon) {
205                 setUrlBarText(url);
206             }
207 
208             @Override
209             public void onPageFinished(WebView view, String url) {
210                 setUrlBarText(url);
211             }
212 
213             @Override
214             public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
215                 String url = request.getUrl().toString();
216                 // "about:" and "chrome:" schemes are internal to Chromium;
217                 // don't want these to be dispatched to other apps.
218                 if (url.startsWith("about:") || url.startsWith("chrome:")) {
219                     return false;
220                 }
221                 boolean allowLaunchingApps = request.hasGesture() || request.isRedirect();
222                 return startBrowsingIntent(WebViewBrowserActivity.this, url, allowLaunchingApps);
223             }
224 
225             @Override
226             public void onReceivedError(WebView view, int errorCode, String description,
227                     String failingUrl) {
228                 setUrlFail(true);
229             }
230         });
231 
232         webview.setWebChromeClient(new WebChromeClient() {
233             @Override
234             public Bitmap getDefaultVideoPoster() {
235                 return Bitmap.createBitmap(
236                         new int[] {Color.TRANSPARENT}, 1, 1, Bitmap.Config.ARGB_8888);
237             }
238 
239             @Override
240             public void onGeolocationPermissionsShowPrompt(String origin,
241                     GeolocationPermissions.Callback callback) {
242                 onPermissionRequest(new GeoPermissionRequest(origin, callback));
243             }
244 
245             @Override
246             public void onPermissionRequest(PermissionRequest request) {
247                 WebViewBrowserActivity.this.requestPermissionsForPage(request);
248             }
249         });
250 
251         mWebView = webview;
252         getContainer().addView(
253                 webview, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT));
254         setUrlBarText("");
255     }
256 
257     // WebKit permissions which can be granted because either they have no associated Android
258     // permission or the associated Android permission has been granted
canGrant(String webkitPermission)259     private boolean canGrant(String webkitPermission) {
260         String androidPermission = sPermissions.get(webkitPermission);
261         if (androidPermission == NO_ANDROID_PERMISSION) {
262             return true;
263         }
264         return PackageManager.PERMISSION_GRANTED == checkSelfPermission(androidPermission);
265     }
266 
requestPermissionsForPage(PermissionRequest request)267     private void requestPermissionsForPage(PermissionRequest request) {
268         // Deny any unrecognized permissions.
269         for (String webkitPermission : request.getResources()) {
270             if (!sPermissions.containsKey(webkitPermission)) {
271                 Log.w(TAG, "Unrecognized WebKit permission: " + webkitPermission);
272                 request.deny();
273                 return;
274             }
275         }
276 
277         if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
278             request.grant(request.getResources());
279             return;
280         }
281 
282         // Find what Android permissions we need before we can grant these WebKit permissions.
283         ArrayList<String> androidPermissionsNeeded = new ArrayList<String>();
284         for (String webkitPermission : request.getResources()) {
285             if (!canGrant(webkitPermission)) {
286                 // We already checked for unrecognized permissions, and canGrant will skip over
287                 // NO_ANDROID_PERMISSION cases, so this is guaranteed to be a regular Android
288                 // permission.
289                 String androidPermission = sPermissions.get(webkitPermission);
290                 androidPermissionsNeeded.add(androidPermission);
291             }
292         }
293 
294         // If there are no such Android permissions, grant the WebKit permissions immediately.
295         if (androidPermissionsNeeded.isEmpty()) {
296             request.grant(request.getResources());
297             return;
298         }
299 
300         // Otherwise, file a new request
301         if (mNextRequestKey == Integer.MAX_VALUE) {
302             Log.e(TAG, "Too many permission requests");
303             return;
304         }
305         int requestCode = mNextRequestKey;
306         mNextRequestKey++;
307         mPendingRequests.append(requestCode, request);
308         requestPermissions(androidPermissionsNeeded.toArray(new String[0]), requestCode);
309     }
310 
311     @Override
onRequestPermissionsResult(int requestCode, String permissions[], int[] grantResults)312     public void onRequestPermissionsResult(int requestCode,
313             String permissions[], int[] grantResults) {
314         // Verify that we can now grant all the requested permissions. Note that although grant()
315         // takes a list of permissions, grant() is actually all-or-nothing. If there are any
316         // requested permissions not included in the granted permissions, all will be denied.
317         PermissionRequest request = mPendingRequests.get(requestCode);
318         for (String webkitPermission : request.getResources()) {
319             if (!canGrant(webkitPermission)) {
320                 request.deny();
321                 return;
322             }
323         }
324         request.grant(request.getResources());
325         mPendingRequests.delete(requestCode);
326     }
327 
loadUrlFromUrlBar(View view)328     public void loadUrlFromUrlBar(View view) {
329         String url = mUrlBar.getText().toString();
330         try {
331             URI uri = new URI(url);
332             url = (uri.getScheme() == null) ? "http://" + uri.toString() : uri.toString();
333         } catch (URISyntaxException e) {
334             String message = "<html><body>URISyntaxException: " + e.getMessage() + "</body></html>";
335             mWebView.loadData(message, "text/html", "UTF-8");
336             setUrlFail(true);
337             return;
338         }
339 
340         setUrlBarText(url);
341         setUrlFail(false);
342         loadUrl(url);
343         hideKeyboard(mUrlBar);
344     }
345 
showPopup(View v)346     public void showPopup(View v) {
347         PopupMenu popup = new PopupMenu(this, v);
348         popup.setOnMenuItemClickListener(this);
349         popup.inflate(R.menu.main_menu);
350         popup.show();
351     }
352 
353     @Override
onMenuItemClick(MenuItem item)354     public boolean onMenuItemClick(MenuItem item) {
355         switch(item.getItemId()) {
356             case R.id.menu_reset_webview:
357                 if (mWebView != null) {
358                     ViewGroup container = getContainer();
359                     container.removeView(mWebView);
360                     mWebView.destroy();
361                     mWebView = null;
362                 }
363                 createAndInitializeWebView();
364                 return true;
365             case R.id.menu_about:
366                 about();
367                 hideKeyboard(mUrlBar);
368                 return true;
369             default:
370                 return false;
371         }
372     }
373 
initializeSettings(WebSettings settings)374     private void initializeSettings(WebSettings settings) {
375         settings.setJavaScriptEnabled(true);
376 
377         // configure local storage apis and their database paths.
378         settings.setAppCachePath(getDir("appcache", 0).getPath());
379         settings.setGeolocationDatabasePath(getDir("geolocation", 0).getPath());
380         settings.setDatabasePath(getDir("databases", 0).getPath());
381 
382         settings.setAppCacheEnabled(true);
383         settings.setGeolocationEnabled(true);
384         settings.setDatabaseEnabled(true);
385         settings.setDomStorageEnabled(true);
386     }
387 
about()388     private void about() {
389         WebSettings settings = mWebView.getSettings();
390         StringBuilder summary = new StringBuilder();
391         summary.append("WebView version : " + mWebViewVersion + "\n");
392 
393         for (Method method : settings.getClass().getMethods()) {
394             if (!methodIsSimpleInspector(method)) continue;
395             try {
396                 summary.append(method.getName() + " : " + method.invoke(settings) + "\n");
397             } catch (IllegalAccessException e) {
398             } catch (InvocationTargetException e) { }
399         }
400 
401         AlertDialog dialog = new AlertDialog.Builder(this)
402                 .setTitle(getResources().getString(R.string.menu_about))
403                 .setMessage(summary)
404                 .setPositiveButton("OK", null)
405                 .create();
406         dialog.show();
407         dialog.getWindow().setLayout(LayoutParams.FILL_PARENT, LayoutParams.FILL_PARENT);
408     }
409 
410     // Returns true is a method has no arguments and returns either a boolean or a String.
methodIsSimpleInspector(Method method)411     private boolean methodIsSimpleInspector(Method method) {
412         Class<?> returnType = method.getReturnType();
413         return ((returnType.equals(boolean.class) || returnType.equals(String.class))
414                 && method.getParameterTypes().length == 0);
415     }
416 
loadUrl(String url)417     private void loadUrl(String url) {
418         // Request read access if necessary
419         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
420                 && "file".equals(Uri.parse(url).getScheme())
421                 && PackageManager.PERMISSION_DENIED
422                         == checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE)) {
423             requestPermissionsForPage(new FilePermissionRequest(url));
424         }
425 
426         // If it is file:// and we don't have permission, they'll get the "Webpage not available"
427         // "net::ERR_ACCESS_DENIED" page. When we get permission, FilePermissionRequest.grant()
428         // will reload.
429         mWebView.loadUrl(url);
430         mWebView.requestFocus();
431     }
432 
setUrlBarText(String url)433     private void setUrlBarText(String url) {
434         mUrlBar.setText(url, TextView.BufferType.EDITABLE);
435     }
436 
setUrlFail(boolean fail)437     private void setUrlFail(boolean fail) {
438         mUrlBar.setTextColor(fail ? Color.RED : Color.BLACK);
439     }
440 
441     /**
442      * Hides the keyboard.
443      * @param view The {@link View} that is currently accepting input.
444      * @return Whether the keyboard was visible before.
445      */
hideKeyboard(View view)446     private static boolean hideKeyboard(View view) {
447         InputMethodManager imm = (InputMethodManager) view.getContext().getSystemService(
448                 Context.INPUT_METHOD_SERVICE);
449         return imm.hideSoftInputFromWindow(view.getWindowToken(), 0);
450     }
451 
getUrlFromIntent(Intent intent)452     private static String getUrlFromIntent(Intent intent) {
453         return intent != null ? intent.getDataString() : null;
454     }
455 
456     static final Pattern BROWSER_URI_SCHEMA = Pattern.compile(
457             "(?i)"   // switch on case insensitive matching
458             + "("    // begin group for schema
459             + "(?:http|https|file):\\/\\/"
460             + "|(?:inline|data|about|chrome|javascript):"
461             + ")"
462             + "(.*)");
463 
startBrowsingIntent(Context context, String url, boolean allowLaunchingApps)464     private static boolean startBrowsingIntent(Context context, String url,
465             boolean allowLaunchingApps) {
466         Intent intent;
467         // Perform generic parsing of the URI to turn it into an Intent.
468         try {
469             intent = Intent.parseUri(url, Intent.URI_INTENT_SCHEME);
470         } catch (Exception ex) {
471             Log.w(TAG, "Bad URI " + url, ex);
472             return false;
473         }
474         // Check for regular URIs that WebView supports by itself, but also
475         // check if there is a specialized app that had registered itself
476         // for this kind of an intent.
477         Matcher m = BROWSER_URI_SCHEMA.matcher(url);
478         if (m.matches() && !isSpecializedHandlerAvailable(context, intent)) {
479             return false;
480         }
481         // Sanitize the Intent, ensuring web pages can not bypass browser
482         // security (only access to BROWSABLE activities).
483         intent.addCategory(Intent.CATEGORY_BROWSABLE);
484         intent.setComponent(null);
485         Intent selector = intent.getSelector();
486         if (selector != null) {
487             selector.addCategory(Intent.CATEGORY_BROWSABLE);
488             selector.setComponent(null);
489         }
490 
491         // Pass the package name as application ID so that the intent from the
492         // same application can be opened in the same tab.
493         intent.putExtra(Browser.EXTRA_APPLICATION_ID, context.getPackageName());
494         try {
495             if (allowLaunchingApps) {
496                 context.startActivity(intent);
497             }
498             return true;
499         } catch (ActivityNotFoundException ex) {
500             Log.w(TAG, "No application can handle " + url);
501         }
502         return false;
503     }
504 
505     /**
506      * Search for intent handlers that are specific to the scheme of the URL in the intent.
507      */
isSpecializedHandlerAvailable(Context context, Intent intent)508     private static boolean isSpecializedHandlerAvailable(Context context, Intent intent) {
509         PackageManager pm = context.getPackageManager();
510         List<ResolveInfo> handlers = pm.queryIntentActivities(intent,
511                 PackageManager.GET_RESOLVED_FILTER);
512         if (handlers == null || handlers.size() == 0) {
513             return false;
514         }
515         for (ResolveInfo resolveInfo : handlers) {
516             if (!isNullOrGenericHandler(resolveInfo.filter)) {
517                 return true;
518             }
519         }
520         return false;
521     }
522 
isNullOrGenericHandler(IntentFilter filter)523     private static boolean isNullOrGenericHandler(IntentFilter filter) {
524         return filter == null
525                 || (filter.countDataAuthorities() == 0 && filter.countDataPaths() == 0);
526     }
527 }
528