// Copyright 2015 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. package org.chromium.webview_shell; import android.Manifest; import android.app.Activity; import android.app.AlertDialog; import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.graphics.Bitmap; import android.graphics.Color; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.provider.Browser; import android.util.Log; import android.util.SparseArray; import android.view.KeyEvent; import android.view.MenuItem; import android.view.View; import android.view.View.OnKeyListener; import android.view.ViewGroup; import android.view.ViewGroup.LayoutParams; import android.view.inputmethod.InputMethodManager; import android.webkit.GeolocationPermissions; import android.webkit.PermissionRequest; import android.webkit.WebChromeClient; import android.webkit.WebResourceRequest; import android.webkit.WebSettings; import android.webkit.WebView; import android.webkit.WebViewClient; import android.widget.EditText; import android.widget.PopupMenu; import android.widget.TextView; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.net.URI; import java.net.URISyntaxException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * This activity is designed for starting a "mini-browser" for manual testing of WebView. * It takes an optional URL as an argument, and displays the page. There is a URL bar * on top of the webview for manually specifying URLs to load. */ public class WebViewBrowserActivity extends Activity implements PopupMenu.OnMenuItemClickListener { private static final String TAG = "WebViewShell"; // Our imaginary Android permission to associate with the WebKit geo permission private static final String RESOURCE_GEO = "RESOURCE_GEO"; // Our imaginary WebKit permission to request when loading a file:// URL private static final String RESOURCE_FILE_URL = "RESOURCE_FILE_URL"; // WebKit permissions with no corresponding Android permission can always be granted private static final String NO_ANDROID_PERMISSION = "NO_ANDROID_PERMISSION"; // Map from WebKit permissions to Android permissions private static final HashMap<String, String> sPermissions; static { sPermissions = new HashMap<String, String>(); sPermissions.put(RESOURCE_GEO, Manifest.permission.ACCESS_FINE_LOCATION); sPermissions.put(RESOURCE_FILE_URL, Manifest.permission.READ_EXTERNAL_STORAGE); sPermissions.put(PermissionRequest.RESOURCE_AUDIO_CAPTURE, Manifest.permission.RECORD_AUDIO); sPermissions.put(PermissionRequest.RESOURCE_MIDI_SYSEX, NO_ANDROID_PERMISSION); sPermissions.put(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID, NO_ANDROID_PERMISSION); sPermissions.put(PermissionRequest.RESOURCE_VIDEO_CAPTURE, Manifest.permission.CAMERA); } private static final Pattern WEBVIEW_VERSION_PATTERN = Pattern.compile("(Chrome/)([\\d\\.]+)\\s"); private EditText mUrlBar; private WebView mWebView; private String mWebViewVersion; // Each time we make a request, store it here with an int key. onRequestPermissionsResult will // look up the request in order to grant the approprate permissions. private SparseArray<PermissionRequest> mPendingRequests = new SparseArray<PermissionRequest>(); private int mNextRequestKey = 0; // Work around our wonky API by wrapping a geo permission prompt inside a regular // PermissionRequest. private static class GeoPermissionRequest extends PermissionRequest { private String mOrigin; private GeolocationPermissions.Callback mCallback; public GeoPermissionRequest(String origin, GeolocationPermissions.Callback callback) { mOrigin = origin; mCallback = callback; } public Uri getOrigin() { return Uri.parse(mOrigin); } public String[] getResources() { return new String[] { WebViewBrowserActivity.RESOURCE_GEO }; } public void grant(String[] resources) { assert resources.length == 1; assert WebViewBrowserActivity.RESOURCE_GEO.equals(resources[0]); mCallback.invoke(mOrigin, true, false); } public void deny() { mCallback.invoke(mOrigin, false, false); } } // For simplicity, also treat the read access needed for file:// URLs as a regular // PermissionRequest. private class FilePermissionRequest extends PermissionRequest { private String mOrigin; public FilePermissionRequest(String origin) { mOrigin = origin; } public Uri getOrigin() { return Uri.parse(mOrigin); } public String[] getResources() { return new String[] { WebViewBrowserActivity.RESOURCE_FILE_URL }; } public void grant(String[] resources) { assert resources.length == 1; assert WebViewBrowserActivity.RESOURCE_FILE_URL.equals(resources[0]); // Try again now that we have read access. WebViewBrowserActivity.this.mWebView.loadUrl(mOrigin); } public void deny() { // womp womp } } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { WebView.setWebContentsDebuggingEnabled(true); } setContentView(R.layout.activity_webview_browser); mUrlBar = (EditText) findViewById(R.id.url_field); mUrlBar.setOnKeyListener(new OnKeyListener() { public boolean onKey(View view, int keyCode, KeyEvent event) { if (keyCode == KeyEvent.KEYCODE_ENTER && event.getAction() == KeyEvent.ACTION_UP) { loadUrlFromUrlBar(view); return true; } return false; } }); createAndInitializeWebView(); String url = getUrlFromIntent(getIntent()); if (url != null) { setUrlBarText(url); setUrlFail(false); loadUrlFromUrlBar(mUrlBar); } } ViewGroup getContainer() { return (ViewGroup) findViewById(R.id.container); } private void createAndInitializeWebView() { WebView webview = new WebView(this); WebSettings settings = webview.getSettings(); initializeSettings(settings); Matcher matcher = WEBVIEW_VERSION_PATTERN.matcher(settings.getUserAgentString()); if (matcher.find()) { mWebViewVersion = matcher.group(2); } else { mWebViewVersion = "-"; } setTitle(getResources().getString(R.string.title_activity_browser) + " " + mWebViewVersion); webview.setWebViewClient(new WebViewClient() { @Override public void onPageStarted(WebView view, String url, Bitmap favicon) { setUrlBarText(url); } @Override public void onPageFinished(WebView view, String url) { setUrlBarText(url); } @Override public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { String url = request.getUrl().toString(); // "about:" and "chrome:" schemes are internal to Chromium; // don't want these to be dispatched to other apps. if (url.startsWith("about:") || url.startsWith("chrome:")) { return false; } boolean allowLaunchingApps = request.hasGesture() || request.isRedirect(); return startBrowsingIntent(WebViewBrowserActivity.this, url, allowLaunchingApps); } @Override public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) { setUrlFail(true); } }); webview.setWebChromeClient(new WebChromeClient() { @Override public Bitmap getDefaultVideoPoster() { return Bitmap.createBitmap( new int[] {Color.TRANSPARENT}, 1, 1, Bitmap.Config.ARGB_8888); } @Override public void onGeolocationPermissionsShowPrompt(String origin, GeolocationPermissions.Callback callback) { onPermissionRequest(new GeoPermissionRequest(origin, callback)); } @Override public void onPermissionRequest(PermissionRequest request) { WebViewBrowserActivity.this.requestPermissionsForPage(request); } }); mWebView = webview; getContainer().addView( webview, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); setUrlBarText(""); } // WebKit permissions which can be granted because either they have no associated Android // permission or the associated Android permission has been granted private boolean canGrant(String webkitPermission) { String androidPermission = sPermissions.get(webkitPermission); if (androidPermission == NO_ANDROID_PERMISSION) { return true; } return PackageManager.PERMISSION_GRANTED == checkSelfPermission(androidPermission); } private void requestPermissionsForPage(PermissionRequest request) { // Deny any unrecognized permissions. for (String webkitPermission : request.getResources()) { if (!sPermissions.containsKey(webkitPermission)) { Log.w(TAG, "Unrecognized WebKit permission: " + webkitPermission); request.deny(); return; } } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { request.grant(request.getResources()); return; } // Find what Android permissions we need before we can grant these WebKit permissions. ArrayList<String> androidPermissionsNeeded = new ArrayList<String>(); for (String webkitPermission : request.getResources()) { if (!canGrant(webkitPermission)) { // We already checked for unrecognized permissions, and canGrant will skip over // NO_ANDROID_PERMISSION cases, so this is guaranteed to be a regular Android // permission. String androidPermission = sPermissions.get(webkitPermission); androidPermissionsNeeded.add(androidPermission); } } // If there are no such Android permissions, grant the WebKit permissions immediately. if (androidPermissionsNeeded.isEmpty()) { request.grant(request.getResources()); return; } // Otherwise, file a new request if (mNextRequestKey == Integer.MAX_VALUE) { Log.e(TAG, "Too many permission requests"); return; } int requestCode = mNextRequestKey; mNextRequestKey++; mPendingRequests.append(requestCode, request); requestPermissions(androidPermissionsNeeded.toArray(new String[0]), requestCode); } @Override public void onRequestPermissionsResult(int requestCode, String permissions[], int[] grantResults) { // Verify that we can now grant all the requested permissions. Note that although grant() // takes a list of permissions, grant() is actually all-or-nothing. If there are any // requested permissions not included in the granted permissions, all will be denied. PermissionRequest request = mPendingRequests.get(requestCode); for (String webkitPermission : request.getResources()) { if (!canGrant(webkitPermission)) { request.deny(); return; } } request.grant(request.getResources()); mPendingRequests.delete(requestCode); } public void loadUrlFromUrlBar(View view) { String url = mUrlBar.getText().toString(); try { URI uri = new URI(url); url = (uri.getScheme() == null) ? "http://" + uri.toString() : uri.toString(); } catch (URISyntaxException e) { String message = "<html><body>URISyntaxException: " + e.getMessage() + "</body></html>"; mWebView.loadData(message, "text/html", "UTF-8"); setUrlFail(true); return; } setUrlBarText(url); setUrlFail(false); loadUrl(url); hideKeyboard(mUrlBar); } public void showPopup(View v) { PopupMenu popup = new PopupMenu(this, v); popup.setOnMenuItemClickListener(this); popup.inflate(R.menu.main_menu); popup.show(); } @Override public boolean onMenuItemClick(MenuItem item) { switch(item.getItemId()) { case R.id.menu_reset_webview: if (mWebView != null) { ViewGroup container = getContainer(); container.removeView(mWebView); mWebView.destroy(); mWebView = null; } createAndInitializeWebView(); return true; case R.id.menu_about: about(); hideKeyboard(mUrlBar); return true; default: return false; } } private void initializeSettings(WebSettings settings) { settings.setJavaScriptEnabled(true); // configure local storage apis and their database paths. settings.setAppCachePath(getDir("appcache", 0).getPath()); settings.setGeolocationDatabasePath(getDir("geolocation", 0).getPath()); settings.setDatabasePath(getDir("databases", 0).getPath()); settings.setAppCacheEnabled(true); settings.setGeolocationEnabled(true); settings.setDatabaseEnabled(true); settings.setDomStorageEnabled(true); } private void about() { WebSettings settings = mWebView.getSettings(); StringBuilder summary = new StringBuilder(); summary.append("WebView version : " + mWebViewVersion + "\n"); for (Method method : settings.getClass().getMethods()) { if (!methodIsSimpleInspector(method)) continue; try { summary.append(method.getName() + " : " + method.invoke(settings) + "\n"); } catch (IllegalAccessException e) { } catch (InvocationTargetException e) { } } AlertDialog dialog = new AlertDialog.Builder(this) .setTitle(getResources().getString(R.string.menu_about)) .setMessage(summary) .setPositiveButton("OK", null) .create(); dialog.show(); dialog.getWindow().setLayout(LayoutParams.FILL_PARENT, LayoutParams.FILL_PARENT); } // Returns true is a method has no arguments and returns either a boolean or a String. private boolean methodIsSimpleInspector(Method method) { Class<?> returnType = method.getReturnType(); return ((returnType.equals(boolean.class) || returnType.equals(String.class)) && method.getParameterTypes().length == 0); } private void loadUrl(String url) { // Request read access if necessary if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && "file".equals(Uri.parse(url).getScheme()) && PackageManager.PERMISSION_DENIED == checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE)) { requestPermissionsForPage(new FilePermissionRequest(url)); } // If it is file:// and we don't have permission, they'll get the "Webpage not available" // "net::ERR_ACCESS_DENIED" page. When we get permission, FilePermissionRequest.grant() // will reload. mWebView.loadUrl(url); mWebView.requestFocus(); } private void setUrlBarText(String url) { mUrlBar.setText(url, TextView.BufferType.EDITABLE); } private void setUrlFail(boolean fail) { mUrlBar.setTextColor(fail ? Color.RED : Color.BLACK); } /** * Hides the keyboard. * @param view The {@link View} that is currently accepting input. * @return Whether the keyboard was visible before. */ private static boolean hideKeyboard(View view) { InputMethodManager imm = (InputMethodManager) view.getContext().getSystemService( Context.INPUT_METHOD_SERVICE); return imm.hideSoftInputFromWindow(view.getWindowToken(), 0); } private static String getUrlFromIntent(Intent intent) { return intent != null ? intent.getDataString() : null; } static final Pattern BROWSER_URI_SCHEMA = Pattern.compile( "(?i)" // switch on case insensitive matching + "(" // begin group for schema + "(?:http|https|file):\\/\\/" + "|(?:inline|data|about|chrome|javascript):" + ")" + "(.*)"); private static boolean startBrowsingIntent(Context context, String url, boolean allowLaunchingApps) { Intent intent; // Perform generic parsing of the URI to turn it into an Intent. try { intent = Intent.parseUri(url, Intent.URI_INTENT_SCHEME); } catch (Exception ex) { Log.w(TAG, "Bad URI " + url, ex); return false; } // Check for regular URIs that WebView supports by itself, but also // check if there is a specialized app that had registered itself // for this kind of an intent. Matcher m = BROWSER_URI_SCHEMA.matcher(url); if (m.matches() && !isSpecializedHandlerAvailable(context, intent)) { return false; } // Sanitize the Intent, ensuring web pages can not bypass browser // security (only access to BROWSABLE activities). intent.addCategory(Intent.CATEGORY_BROWSABLE); intent.setComponent(null); Intent selector = intent.getSelector(); if (selector != null) { selector.addCategory(Intent.CATEGORY_BROWSABLE); selector.setComponent(null); } // Pass the package name as application ID so that the intent from the // same application can be opened in the same tab. intent.putExtra(Browser.EXTRA_APPLICATION_ID, context.getPackageName()); try { if (allowLaunchingApps) { context.startActivity(intent); } return true; } catch (ActivityNotFoundException ex) { Log.w(TAG, "No application can handle " + url); } return false; } /** * Search for intent handlers that are specific to the scheme of the URL in the intent. */ private static boolean isSpecializedHandlerAvailable(Context context, Intent intent) { PackageManager pm = context.getPackageManager(); List<ResolveInfo> handlers = pm.queryIntentActivities(intent, PackageManager.GET_RESOLVED_FILTER); if (handlers == null || handlers.size() == 0) { return false; } for (ResolveInfo resolveInfo : handlers) { if (!isNullOrGenericHandler(resolveInfo.filter)) { return true; } } return false; } private static boolean isNullOrGenericHandler(IntentFilter filter) { return filter == null || (filter.countDataAuthorities() == 0 && filter.countDataPaths() == 0); } }