Skip to content

Commit 7a4762f

Browse files
committed
feat: Implement webview permission handling for camera, microphone, and geolocation, add clipboard
1 parent b48c229 commit 7a4762f

File tree

3 files changed

+349
-0
lines changed

3 files changed

+349
-0
lines changed

src/plugins/browser/android/com/foxdebug/browser/Browser.java

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import android.view.inputmethod.InputMethodManager;
2121
import android.webkit.ConsoleMessage;
2222
import android.webkit.ValueCallback;
23+
import android.webkit.PermissionRequest;
2324
import android.webkit.WebChromeClient;
2425
import android.webkit.WebSettings;
2526
import android.webkit.WebView;
@@ -49,6 +50,9 @@
4950
import android.widget.Toast;
5051
import android.os.Handler;
5152
import android.os.Looper;
53+
import android.content.ClipboardManager;
54+
import android.content.ClipData;
55+
import android.webkit.JavascriptInterface;
5256

5357

5458

@@ -84,6 +88,8 @@ public class Browser extends LinearLayout {
8488

8589
ValueCallback<Uri[]> filePathCallback;
8690
final int REQUEST_SELECT_FILE = 1;
91+
92+
private BrowserActivity permissionHandler;
8793

8894
public Browser(Context context, Ui.Theme theme, Boolean onlyConsole) {
8995
super(context);
@@ -208,6 +214,25 @@ public void onDownloadStart(String url, String userAgent,
208214
settings.setAllowContentAccess(true);
209215
settings.setDisplayZoomControls(false);
210216
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");
211236

212237
webViewContainer = new LinearLayout(context);
213238
webViewContainer.setGravity(Gravity.CENTER);
@@ -397,6 +422,14 @@ public void setConsoleVisible(boolean visible) {
397422
public void setProgressBarVisible(boolean visible) {
398423
loading.setVisibility(visible ? View.VISIBLE : View.GONE);
399424
}
425+
426+
public void setPermissionHandler(BrowserActivity handler) {
427+
this.permissionHandler = handler;
428+
}
429+
430+
public BrowserActivity getPermissionHandler() {
431+
return this.permissionHandler;
432+
}
400433

401434
private void updateViewportDimension(int width, int height) {
402435
String script =
@@ -599,6 +632,9 @@ public void exit() {
599632
class BrowserChromeClient extends WebChromeClient {
600633

601634
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<>();
602638

603639
public BrowserChromeClient(Browser browser) {
604640
super();
@@ -654,6 +690,138 @@ public boolean onShowFileChooser(
654690

655691
return true;
656692
}
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+
}
657825
}
658826

659827
class BrowserWebViewClient extends WebViewClient {
@@ -682,6 +850,37 @@ public void onPageStarted(WebView view, String url, Bitmap icon) {
682850
public void onPageFinished(WebView view, String url) {
683851
super.onPageFinished(view, url);
684852
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);
685884

686885
// Inject console for external sites
687886
// 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) {
762961
browser.setDesktopMode();
763962
}
764963
}
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

Comments
 (0)