|
20 | 20 | import android.view.inputmethod.InputMethodManager; |
21 | 21 | import android.webkit.ConsoleMessage; |
22 | 22 | import android.webkit.ValueCallback; |
| 23 | +import android.webkit.PermissionRequest; |
23 | 24 | import android.webkit.WebChromeClient; |
24 | 25 | import android.webkit.WebSettings; |
25 | 26 | import android.webkit.WebView; |
|
49 | 50 | import android.widget.Toast; |
50 | 51 | import android.os.Handler; |
51 | 52 | import android.os.Looper; |
| 53 | +import android.content.ClipboardManager; |
| 54 | +import android.content.ClipData; |
| 55 | +import android.webkit.JavascriptInterface; |
52 | 56 |
|
53 | 57 |
|
54 | 58 |
|
@@ -84,6 +88,8 @@ public class Browser extends LinearLayout { |
84 | 88 |
|
85 | 89 | ValueCallback<Uri[]> filePathCallback; |
86 | 90 | final int REQUEST_SELECT_FILE = 1; |
| 91 | + |
| 92 | + private BrowserActivity permissionHandler; |
87 | 93 |
|
88 | 94 | public Browser(Context context, Ui.Theme theme, Boolean onlyConsole) { |
89 | 95 | super(context); |
@@ -208,6 +214,25 @@ public void onDownloadStart(String url, String userAgent, |
208 | 214 | settings.setAllowContentAccess(true); |
209 | 215 | settings.setDisplayZoomControls(false); |
210 | 216 | settings.setDomStorageEnabled(true); |
| 217 | + |
| 218 | + // Enable media streaming (camera/microphone) |
| 219 | + settings.setMediaPlaybackRequiresUserGesture(false); |
| 220 | + settings.setJavaScriptCanOpenWindowsAutomatically(true); |
| 221 | + |
| 222 | + // Allow mixed content (needed for some camera APIs on HTTPS sites) |
| 223 | + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { |
| 224 | + settings.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW); |
| 225 | + } |
| 226 | + |
| 227 | + // Additional settings for file access and databases |
| 228 | + settings.setAllowFileAccess(true); |
| 229 | + settings.setDatabaseEnabled(true); |
| 230 | + |
| 231 | + // Enable hardware acceleration for video rendering |
| 232 | + webView.setLayerType(View.LAYER_TYPE_HARDWARE, null); |
| 233 | + |
| 234 | + // Add clipboard bridge for JavaScript access |
| 235 | + webView.addJavascriptInterface(new ClipboardBridge(context), "AndroidClipboard"); |
211 | 236 |
|
212 | 237 | webViewContainer = new LinearLayout(context); |
213 | 238 | webViewContainer.setGravity(Gravity.CENTER); |
@@ -397,6 +422,14 @@ public void setConsoleVisible(boolean visible) { |
397 | 422 | public void setProgressBarVisible(boolean visible) { |
398 | 423 | loading.setVisibility(visible ? View.VISIBLE : View.GONE); |
399 | 424 | } |
| 425 | + |
| 426 | + public void setPermissionHandler(BrowserActivity handler) { |
| 427 | + this.permissionHandler = handler; |
| 428 | + } |
| 429 | + |
| 430 | + public BrowserActivity getPermissionHandler() { |
| 431 | + return this.permissionHandler; |
| 432 | + } |
400 | 433 |
|
401 | 434 | private void updateViewportDimension(int width, int height) { |
402 | 435 | String script = |
@@ -599,6 +632,9 @@ public void exit() { |
599 | 632 | class BrowserChromeClient extends WebChromeClient { |
600 | 633 |
|
601 | 634 | Browser browser; |
| 635 | + |
| 636 | + // Cache granted permissions per origin to avoid re-prompting (e.g., when switching cameras) |
| 637 | + private java.util.Set<String> grantedPermissions = new java.util.HashSet<>(); |
602 | 638 |
|
603 | 639 | public BrowserChromeClient(Browser browser) { |
604 | 640 | super(); |
@@ -654,6 +690,138 @@ public boolean onShowFileChooser( |
654 | 690 |
|
655 | 691 | return true; |
656 | 692 | } |
| 693 | + |
| 694 | + @Override |
| 695 | + public void onPermissionRequest(final PermissionRequest request) { |
| 696 | + final String[] resources = request.getResources(); |
| 697 | + final Uri origin = request.getOrigin(); |
| 698 | + final String originKey = origin != null ? origin.toString() : ""; |
| 699 | + |
| 700 | + // Check if all requested permissions are already granted for this origin |
| 701 | + boolean allCached = true; |
| 702 | + for (String resource : resources) { |
| 703 | + String cacheKey = originKey + "|" + resource; |
| 704 | + if (!grantedPermissions.contains(cacheKey)) { |
| 705 | + allCached = false; |
| 706 | + break; |
| 707 | + } |
| 708 | + } |
| 709 | + |
| 710 | + if (allCached) { |
| 711 | + request.grant(resources); |
| 712 | + return; |
| 713 | + } |
| 714 | + |
| 715 | + // Build a human-readable list with emojis for better visual appeal |
| 716 | + StringBuilder permissionList = new StringBuilder(); |
| 717 | + for (String resource : resources) { |
| 718 | + if (resource.equals(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) { |
| 719 | + permissionList.append("📷 Camera\n"); |
| 720 | + } else if (resource.equals(PermissionRequest.RESOURCE_AUDIO_CAPTURE)) { |
| 721 | + permissionList.append("🎤 Microphone\n"); |
| 722 | + } else if (resource.equals(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID)) { |
| 723 | + permissionList.append("🔐 Protected Media\n"); |
| 724 | + } else if (resource.equals(PermissionRequest.RESOURCE_MIDI_SYSEX)) { |
| 725 | + permissionList.append("🎹 MIDI Device\n"); |
| 726 | + } else { |
| 727 | + permissionList.append("🔧 ").append(resource).append("\n"); |
| 728 | + } |
| 729 | + } |
| 730 | + |
| 731 | + // Get the site name from origin |
| 732 | + String siteName = origin != null ? origin.getHost() : "This site"; |
| 733 | + if (siteName == null || siteName.isEmpty()) { |
| 734 | + siteName = "This site"; |
| 735 | + } |
| 736 | + |
| 737 | + final String message = siteName + " wants to access:\n\n" + permissionList.toString(); |
| 738 | + |
| 739 | + new Handler(Looper.getMainLooper()).post(() -> { |
| 740 | + AlertDialog dialog = new AlertDialog.Builder(browser.context) |
| 741 | + .setTitle("🔔 Permission Request") |
| 742 | + .setMessage(message) |
| 743 | + .setPositiveButton("Allow", (dlg, which) -> { |
| 744 | + // Cache the granted permissions for this origin |
| 745 | + for (String resource : resources) { |
| 746 | + String cacheKey = originKey + "|" + resource; |
| 747 | + grantedPermissions.add(cacheKey); |
| 748 | + } |
| 749 | + |
| 750 | + // Check if we have a permission handler (activity) to handle runtime permissions |
| 751 | + BrowserActivity handler = browser.getPermissionHandler(); |
| 752 | + if (handler != null) { |
| 753 | + handler.handlePermissionRequest(request, resources); |
| 754 | + } else { |
| 755 | + // Fallback: directly grant if no handler |
| 756 | + request.grant(resources); |
| 757 | + } |
| 758 | + }) |
| 759 | + .setNegativeButton("Block", (dlg, which) -> { |
| 760 | + request.deny(); |
| 761 | + }) |
| 762 | + .setOnCancelListener(dlg -> { |
| 763 | + request.deny(); |
| 764 | + }) |
| 765 | + .setCancelable(true) |
| 766 | + .create(); |
| 767 | + |
| 768 | + dialog.show(); |
| 769 | + }); |
| 770 | + } |
| 771 | + |
| 772 | + @Override |
| 773 | + public void onPermissionRequestCanceled(PermissionRequest request) { |
| 774 | + super.onPermissionRequestCanceled(request); |
| 775 | + } |
| 776 | + |
| 777 | + // Geolocation permission handling |
| 778 | + @Override |
| 779 | + public void onGeolocationPermissionsShowPrompt(final String origin, |
| 780 | + final android.webkit.GeolocationPermissions.Callback callback) { |
| 781 | + |
| 782 | + String cacheKey = origin + "|geolocation"; |
| 783 | + |
| 784 | + // Check if already granted |
| 785 | + if (grantedPermissions.contains(cacheKey)) { |
| 786 | + callback.invoke(origin, true, false); |
| 787 | + return; |
| 788 | + } |
| 789 | + |
| 790 | + // Get site name from origin |
| 791 | + String siteName = origin; |
| 792 | + try { |
| 793 | + Uri uri = Uri.parse(origin); |
| 794 | + siteName = uri.getHost() != null ? uri.getHost() : origin; |
| 795 | + } catch (Exception e) { |
| 796 | + // Keep original |
| 797 | + } |
| 798 | + |
| 799 | + final String displayName = siteName; |
| 800 | + |
| 801 | + new Handler(Looper.getMainLooper()).post(() -> { |
| 802 | + new AlertDialog.Builder(browser.context) |
| 803 | + .setTitle("📍 Location Request") |
| 804 | + .setMessage(displayName + " wants to access your location") |
| 805 | + .setPositiveButton("Allow", (dialog, which) -> { |
| 806 | + grantedPermissions.add(cacheKey); |
| 807 | + // Check Android runtime location permission |
| 808 | + BrowserActivity handler = browser.getPermissionHandler(); |
| 809 | + if (handler != null) { |
| 810 | + handler.handleGeolocationPermission(origin, callback); |
| 811 | + } else { |
| 812 | + callback.invoke(origin, true, false); |
| 813 | + } |
| 814 | + }) |
| 815 | + .setNegativeButton("Block", (dialog, which) -> { |
| 816 | + callback.invoke(origin, false, false); |
| 817 | + }) |
| 818 | + .setOnCancelListener(dialog -> { |
| 819 | + callback.invoke(origin, false, false); |
| 820 | + }) |
| 821 | + .setCancelable(true) |
| 822 | + .show(); |
| 823 | + }); |
| 824 | + } |
657 | 825 | } |
658 | 826 |
|
659 | 827 | class BrowserWebViewClient extends WebViewClient { |
@@ -682,6 +850,37 @@ public void onPageStarted(WebView view, String url, Bitmap icon) { |
682 | 850 | public void onPageFinished(WebView view, String url) { |
683 | 851 | super.onPageFinished(view, url); |
684 | 852 | browser.setProgressBarVisible(false); |
| 853 | + |
| 854 | + // Inject clipboard polyfill to use native AndroidClipboard bridge |
| 855 | + // Always override because WebView's native clipboard throws permission errors |
| 856 | + String clipboardPolyfill = |
| 857 | + "if (typeof AndroidClipboard !== 'undefined') {" + |
| 858 | + " navigator.clipboard = navigator.clipboard || {};" + |
| 859 | + " navigator.clipboard.readText = function() {" + |
| 860 | + " return new Promise(function(resolve, reject) {" + |
| 861 | + " try {" + |
| 862 | + " var text = AndroidClipboard.getText();" + |
| 863 | + " resolve(text || '');" + |
| 864 | + " } catch(e) {" + |
| 865 | + " reject(e);" + |
| 866 | + " }" + |
| 867 | + " });" + |
| 868 | + " };" + |
| 869 | + " navigator.clipboard.writeText = function(text) {" + |
| 870 | + " return new Promise(function(resolve, reject) {" + |
| 871 | + " try {" + |
| 872 | + " if (AndroidClipboard.setText(text)) {" + |
| 873 | + " resolve();" + |
| 874 | + " } else {" + |
| 875 | + " reject(new Error('Failed to write to clipboard'));" + |
| 876 | + " }" + |
| 877 | + " } catch(e) {" + |
| 878 | + " reject(e);" + |
| 879 | + " }" + |
| 880 | + " });" + |
| 881 | + " };" + |
| 882 | + "}"; |
| 883 | + view.evaluateJavascript(clipboardPolyfill, null); |
685 | 884 |
|
686 | 885 | // Inject console for external sites |
687 | 886 | // this is not a good solution but for now its good, later we'll improve this |
@@ -762,3 +961,44 @@ public void onLoadResource(WebView view, String url) { |
762 | 961 | browser.setDesktopMode(); |
763 | 962 | } |
764 | 963 | } |
| 964 | + |
| 965 | +// Clipboard bridge for JavaScript access |
| 966 | +class ClipboardBridge { |
| 967 | + private Context context; |
| 968 | + |
| 969 | + public ClipboardBridge(Context context) { |
| 970 | + this.context = context; |
| 971 | + } |
| 972 | + |
| 973 | + @JavascriptInterface |
| 974 | + public String getText() { |
| 975 | + try { |
| 976 | + ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); |
| 977 | + if (clipboard != null && clipboard.hasPrimaryClip()) { |
| 978 | + ClipData clip = clipboard.getPrimaryClip(); |
| 979 | + if (clip != null && clip.getItemCount() > 0) { |
| 980 | + CharSequence text = clip.getItemAt(0).getText(); |
| 981 | + return text != null ? text.toString() : ""; |
| 982 | + } |
| 983 | + } |
| 984 | + } catch (Exception e) { |
| 985 | + e.printStackTrace(); |
| 986 | + } |
| 987 | + return ""; |
| 988 | + } |
| 989 | + |
| 990 | + @JavascriptInterface |
| 991 | + public boolean setText(String text) { |
| 992 | + try { |
| 993 | + ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); |
| 994 | + if (clipboard != null) { |
| 995 | + ClipData clip = ClipData.newPlainText("text", text); |
| 996 | + clipboard.setPrimaryClip(clip); |
| 997 | + return true; |
| 998 | + } |
| 999 | + } catch (Exception e) { |
| 1000 | + e.printStackTrace(); |
| 1001 | + } |
| 1002 | + return false; |
| 1003 | + } |
| 1004 | +} |
0 commit comments