Home | History | Annotate | Download | only in webview_shell
      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>();
     76         sPermissions.put(RESOURCE_GEO, Manifest.permission.ACCESS_FINE_LOCATION);
     77         sPermissions.put(RESOURCE_FILE_URL, Manifest.permission.READ_EXTERNAL_STORAGE);
     78         sPermissions.put(PermissionRequest.RESOURCE_AUDIO_CAPTURE,
     79                 Manifest.permission.RECORD_AUDIO);
     80         sPermissions.put(PermissionRequest.RESOURCE_MIDI_SYSEX, NO_ANDROID_PERMISSION);
     81         sPermissions.put(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID, NO_ANDROID_PERMISSION);
     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 
    104         public GeoPermissionRequest(String origin, GeolocationPermissions.Callback callback) {
    105             mOrigin = origin;
    106             mCallback = callback;
    107         }
    108 
    109         public Uri getOrigin() {
    110             return Uri.parse(mOrigin);
    111         }
    112 
    113         public String[] getResources() {
    114             return new String[] { WebViewBrowserActivity.RESOURCE_GEO };
    115         }
    116 
    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 
    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 
    133         public FilePermissionRequest(String origin) {
    134             mOrigin = origin;
    135         }
    136 
    137         public Uri getOrigin() {
    138             return Uri.parse(mOrigin);
    139         }
    140 
    141         public String[] getResources() {
    142             return new String[] { WebViewBrowserActivity.RESOURCE_FILE_URL };
    143         }
    144 
    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 
    152         public void deny() {
    153             // womp womp
    154         }
    155     }
    156 
    157     @Override
    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 
    185     ViewGroup getContainer() {
    186         return (ViewGroup) findViewById(R.id.container);
    187     }
    188 
    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
    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 
    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
    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 
    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 
    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
    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 
    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 
    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.
    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 
    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 
    433     private void setUrlBarText(String url) {
    434         mUrlBar.setText(url, TextView.BufferType.EDITABLE);
    435     }
    436 
    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      */
    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 
    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 
    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      */
    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 
    523     private static boolean isNullOrGenericHandler(IntentFilter filter) {
    524         return filter == null
    525                 || (filter.countDataAuthorities() == 0 && filter.countDataPaths() == 0);
    526     }
    527 }
    528