Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
240 changes: 240 additions & 0 deletions src/plugins/browser/android/com/foxdebug/browser/Browser.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import android.view.inputmethod.InputMethodManager;
import android.webkit.ConsoleMessage;
import android.webkit.ValueCallback;
import android.webkit.PermissionRequest;
import android.webkit.WebChromeClient;
import android.webkit.WebSettings;
import android.webkit.WebView;
Expand Down Expand Up @@ -49,6 +50,9 @@
import android.widget.Toast;
import android.os.Handler;
import android.os.Looper;
import android.content.ClipboardManager;
import android.content.ClipData;
import android.webkit.JavascriptInterface;



Expand Down Expand Up @@ -84,6 +88,8 @@ public class Browser extends LinearLayout {

ValueCallback<Uri[]> filePathCallback;
final int REQUEST_SELECT_FILE = 1;

private BrowserActivity permissionHandler;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Introducing a direct reference to BrowserActivity here creates a tight coupling between the Browser UI component and the BrowserActivity. This can reduce reusability and make independent testing of the Browser class more difficult. Consider using an interface (e.g., PermissionHandlerCallback) to decouple these components, allowing Browser to communicate with any class that implements the interface, rather than a specific Activity.


public Browser(Context context, Ui.Theme theme, Boolean onlyConsole) {
super(context);
Expand Down Expand Up @@ -208,6 +214,25 @@ public void onDownloadStart(String url, String userAgent,
settings.setAllowContentAccess(true);
settings.setDisplayZoomControls(false);
settings.setDomStorageEnabled(true);

// Enable media streaming (camera/microphone)
settings.setMediaPlaybackRequiresUserGesture(false);
settings.setJavaScriptCanOpenWindowsAutomatically(true);
Comment thread
bajrangCoder marked this conversation as resolved.
Outdated

// Allow mixed content (needed for some camera APIs on HTTPS sites)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
settings.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
Comment thread
bajrangCoder marked this conversation as resolved.
Outdated
}

// Additional settings for file access and databases
settings.setAllowFileAccess(true);
settings.setDatabaseEnabled(true);

// Enable hardware acceleration for video rendering
webView.setLayerType(View.LAYER_TYPE_HARDWARE, null);

// Add clipboard bridge for JavaScript access
webView.addJavascriptInterface(new ClipboardBridge(context), "AndroidClipboard");

webViewContainer = new LinearLayout(context);
webViewContainer.setGravity(Gravity.CENTER);
Expand Down Expand Up @@ -397,6 +422,14 @@ public void setConsoleVisible(boolean visible) {
public void setProgressBarVisible(boolean visible) {
loading.setVisibility(visible ? View.VISIBLE : View.GONE);
}

public void setPermissionHandler(BrowserActivity handler) {
this.permissionHandler = handler;
}

public BrowserActivity getPermissionHandler() {
return this.permissionHandler;
}

private void updateViewportDimension(int width, int height) {
String script =
Expand Down Expand Up @@ -599,6 +632,9 @@ public void exit() {
class BrowserChromeClient extends WebChromeClient {

Browser browser;

// Cache granted permissions per origin to avoid re-prompting (e.g., when switching cameras)
private java.util.Set<String> grantedPermissions = new java.util.HashSet<>();

public BrowserChromeClient(Browser browser) {
super();
Expand Down Expand Up @@ -654,6 +690,138 @@ public boolean onShowFileChooser(

return true;
}

@Override
public void onPermissionRequest(final PermissionRequest request) {
final String[] resources = request.getResources();
final Uri origin = request.getOrigin();
final String originKey = origin != null ? origin.toString() : "";

// Check if all requested permissions are already granted for this origin
boolean allCached = true;
for (String resource : resources) {
String cacheKey = originKey + "|" + resource;
if (!grantedPermissions.contains(cacheKey)) {
allCached = false;
break;
}
}

if (allCached) {
request.grant(resources);
return;
}

// Build a human-readable list with emojis for better visual appeal
StringBuilder permissionList = new StringBuilder();
for (String resource : resources) {
if (resource.equals(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) {
permissionList.append("📷 Camera\n");
} else if (resource.equals(PermissionRequest.RESOURCE_AUDIO_CAPTURE)) {
permissionList.append("🎤 Microphone\n");
} else if (resource.equals(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID)) {
permissionList.append("🔐 Protected Media\n");
} else if (resource.equals(PermissionRequest.RESOURCE_MIDI_SYSEX)) {
permissionList.append("🎹 MIDI Device\n");
} else {
permissionList.append("🔧 ").append(resource).append("\n");
}
}

// Get the site name from origin
String siteName = origin != null ? origin.getHost() : "This site";
if (siteName == null || siteName.isEmpty()) {
siteName = "This site";
}

final String message = siteName + " wants to access:\n\n" + permissionList.toString();

new Handler(Looper.getMainLooper()).post(() -> {
AlertDialog dialog = new AlertDialog.Builder(browser.context)
.setTitle("🔔 Permission Request")
.setMessage(message)
.setPositiveButton("Allow", (dlg, which) -> {
// Cache the granted permissions for this origin
for (String resource : resources) {
String cacheKey = originKey + "|" + resource;
grantedPermissions.add(cacheKey);
}

// Check if we have a permission handler (activity) to handle runtime permissions
BrowserActivity handler = browser.getPermissionHandler();
if (handler != null) {
handler.handlePermissionRequest(request, resources);
} else {
// Fallback: directly grant if no handler
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This fallback grants permissions directly if browser.getPermissionHandler() returns null. While BrowserActivity now sets itself as the handler, if for any reason the handler is not set or becomes null, this could bypass Android's runtime permission checks, leading to unexpected behavior or security concerns. It would be safer to deny the request or log a critical error if no handler is available to perform the necessary runtime checks.

Suggested change
// Fallback: directly grant if no handler
request.deny();

request.grant(resources);
}
})
.setNegativeButton("Block", (dlg, which) -> {
request.deny();
})
.setOnCancelListener(dlg -> {
request.deny();
})
.setCancelable(true)
.create();

dialog.show();
});
}

@Override
public void onPermissionRequestCanceled(PermissionRequest request) {
super.onPermissionRequestCanceled(request);
}

// Geolocation permission handling
@Override
public void onGeolocationPermissionsShowPrompt(final String origin,
final android.webkit.GeolocationPermissions.Callback callback) {

String cacheKey = origin + "|geolocation";

// Check if already granted
if (grantedPermissions.contains(cacheKey)) {
callback.invoke(origin, true, false);
return;
}

// Get site name from origin
String siteName = origin;
try {
Uri uri = Uri.parse(origin);
siteName = uri.getHost() != null ? uri.getHost() : origin;
} catch (Exception e) {
// Keep original
}

final String displayName = siteName;

new Handler(Looper.getMainLooper()).post(() -> {
new AlertDialog.Builder(browser.context)
.setTitle("📍 Location Request")
.setMessage(displayName + " wants to access your location")
.setPositiveButton("Allow", (dialog, which) -> {
grantedPermissions.add(cacheKey);
// Check Android runtime location permission
BrowserActivity handler = browser.getPermissionHandler();
if (handler != null) {
handler.handleGeolocationPermission(origin, callback);
} else {
callback.invoke(origin, true, false);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the onPermissionRequest method, this fallback grants geolocation permission directly if browser.getPermissionHandler() is null. This could bypass Android's runtime permission checks for location. It's safer to deny the request (i.e., callback.invoke(origin, false, false)) or log an error if the handler is not available to ensure proper permission management.

Suggested change
callback.invoke(origin, true, false);
callback.invoke(origin, false, false);

}
})
.setNegativeButton("Block", (dialog, which) -> {
callback.invoke(origin, false, false);
})
.setOnCancelListener(dialog -> {
callback.invoke(origin, false, false);
})
.setCancelable(true)
.show();
});
}
}

class BrowserWebViewClient extends WebViewClient {
Expand Down Expand Up @@ -682,6 +850,37 @@ public void onPageStarted(WebView view, String url, Bitmap icon) {
public void onPageFinished(WebView view, String url) {
super.onPageFinished(view, url);
browser.setProgressBarVisible(false);

// Inject clipboard polyfill to use native AndroidClipboard bridge
// Always override because WebView's native clipboard throws permission errors
String clipboardPolyfill =
"if (typeof AndroidClipboard !== 'undefined') {" +
" navigator.clipboard = navigator.clipboard || {};" +
" navigator.clipboard.readText = function() {" +
" return new Promise(function(resolve, reject) {" +
" try {" +
" var text = AndroidClipboard.getText();" +
" resolve(text || '');" +
" } catch(e) {" +
" reject(e);" +
" }" +
" });" +
" };" +
" navigator.clipboard.writeText = function(text) {" +
" return new Promise(function(resolve, reject) {" +
" try {" +
" if (AndroidClipboard.setText(text)) {" +
" resolve();" +
" } else {" +
" reject(new Error('Failed to write to clipboard'));" +
" }" +
" } catch(e) {" +
" reject(e);" +
" }" +
" });" +
" };" +
"}";
view.evaluateJavascript(clipboardPolyfill, null);

// Inject console for external sites
// this is not a good solution but for now its good, later we'll improve this
Expand Down Expand Up @@ -762,3 +961,44 @@ public void onLoadResource(WebView view, String url) {
browser.setDesktopMode();
}
}

// Clipboard bridge for JavaScript access
class ClipboardBridge {
private Context context;

public ClipboardBridge(Context context) {
this.context = context;
}

@JavascriptInterface
public String getText() {
try {
ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
if (clipboard != null && clipboard.hasPrimaryClip()) {
ClipData clip = clipboard.getPrimaryClip();
if (clip != null && clip.getItemCount() > 0) {
CharSequence text = clip.getItemAt(0).getText();
return text != null ? text.toString() : "";
}
}
} catch (Exception e) {
e.printStackTrace();
Comment on lines +933 to +934
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using e.printStackTrace() is generally discouraged in production code as it can clutter logs and doesn't provide structured error information. Consider replacing this with a proper logging mechanism (e.g., Log.e(TAG, "Error getting clipboard text", e);) to ensure errors are handled and reported consistently. Remember to add import android.util.Log; if not already present.

      Log.e("ClipboardBridge", "Error getting clipboard text", e);
    }

}
return "";
}

@JavascriptInterface
public boolean setText(String text) {
try {
ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
if (clipboard != null) {
ClipData clip = ClipData.newPlainText("text", text);
clipboard.setPrimaryClip(clip);
return true;
}
} catch (Exception e) {
e.printStackTrace();
}
Comment on lines +949 to +950
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the getText() method, e.printStackTrace() here should be replaced with a structured logging approach (e.g., Log.e(TAG, "Error setting clipboard text", e);) for better error management and debugging in a production environment. Remember to add import android.util.Log; if not already present.

      Log.e("ClipboardBridge", "Error setting clipboard text", e);
    }

return false;
}
}
Loading