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