Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
10 changes: 10 additions & 0 deletions .changeset/age-378-android-deny-media-capture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@phantom/react-native-webview": patch
---

Honor `mediaCapturePermissionGrantType="deny"` on Android. Previously the Android
setter was a no-op and the value was ignored. Now `RNCWebChromeClient.onPermissionRequest`
short-circuits and calls `request.deny()` before reading the requested resources, showing
the site-attributed `AlertDialog`, or triggering an OS CAMERA/RECORD_AUDIO permission
request. Other grant-type values (and the default `prompt`) preserve the existing Android
prompt behavior, and iOS behavior is unchanged.
12 changes: 12 additions & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,15 @@ android {
}
}
}

testOptions {
unitTests {
// Allow referencing android.* classes from JVM unit tests; the
// RNCWebChromeClient permission tests mock everything they touch.
returnDefaultValues = true
includeAndroidResources = true
}
}
}

def reactNativePath = findNodeModulePath(projectDir, "react-native")
Expand All @@ -107,4 +116,7 @@ dependencies {
implementation 'com.facebook.react:react-native:+'
implementation "org.jetbrains.kotlin:kotlin-stdlib:${safeExtGet('kotlinVersion')}"
implementation "androidx.webkit:webkit:${safeExtGet('webkitVersion')}"

testImplementation "junit:junit:4.13.2"
testImplementation "org.mockito:mockito-core:5.11.0"
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ public class RNCWebChromeClient extends WebChromeClient implements LifecycleEven
protected RNCWebView.ProgressChangedFilter progressChangedFilter = null;
protected boolean mAllowsProtectedMedia = false;

// Mirrors the iOS `mediaCapturePermissionGrantType` prop. Only the `deny`
// value is enforced natively on Android for now; any other value (including
// null) preserves the existing prompt behavior.
protected static final String MEDIA_CAPTURE_GRANT_TYPE_DENY = "deny";
protected String mMediaCapturePermissionGrantType = null;

protected boolean mHasOnOpenWindowEvent = false;

public RNCWebChromeClient(RNCWebView webView) {
Expand Down Expand Up @@ -156,6 +162,15 @@ public void onProgressChanged(WebView webView, int newProgress) {

@Override
public void onPermissionRequest(final PermissionRequest request) {
// Honor `mediaCapturePermissionGrantType="deny"` before touching the
// requested resources, showing a site-attributed AlertDialog, or asking
// the OS for CAMERA/RECORD_AUDIO. This guarantees the dApp browser can
// never trigger a trusted-shell media-capture prompt.
if (MEDIA_CAPTURE_GRANT_TYPE_DENY.equals(mMediaCapturePermissionGrantType)) {
request.deny();
return;
}

permissionRequest = request;
grantedPermissions = new ArrayList<>();
alertPermissions = new ArrayList<>();
Expand Down Expand Up @@ -478,6 +493,15 @@ public void setAllowsProtectedMedia(boolean enabled) {
mAllowsProtectedMedia = enabled;
}

/**
* Set how media-capture permission requests should be handled.
* Only the `deny` value is enforced natively on Android; any other value
* (including null) preserves the existing prompt behavior.
*/
public void setMediaCapturePermissionGrantType(String value) {
mMediaCapturePermissionGrantType = value;
}

public void setHasOnOpenWindowEvent(boolean hasEvent) {
mHasOnOpenWindowEvent = hasEvent;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class RNCWebViewManagerImpl(private val newArch: Boolean = false) {
private var mWebViewConfig: RNCWebViewConfig = RNCWebViewConfig { webView: WebView? -> }
private var mAllowsFullscreenVideo = false
private var mAllowsProtectedMedia = false
private var mMediaCapturePermissionGrantType: String? = null
private var mDownloadingMessage: String? = null
private var mLackPermissionToDownloadMessage: String? = null
private var mHasOnOpenWindowEvent = false
Expand Down Expand Up @@ -167,6 +168,7 @@ class RNCWebViewManagerImpl(private val newArch: Boolean = false) {
}
}
webChromeClient.setAllowsProtectedMedia(mAllowsProtectedMedia);
webChromeClient.setMediaCapturePermissionGrantType(mMediaCapturePermissionGrantType);
webChromeClient.setHasOnOpenWindowEvent(mHasOnOpenWindowEvent);
webView.webChromeClient = webChromeClient
} else {
Expand All @@ -178,6 +180,7 @@ class RNCWebViewManagerImpl(private val newArch: Boolean = false) {
}
}
webChromeClient.setAllowsProtectedMedia(mAllowsProtectedMedia);
webChromeClient.setMediaCapturePermissionGrantType(mMediaCapturePermissionGrantType);
webChromeClient.setHasOnOpenWindowEvent(mHasOnOpenWindowEvent);
webView.webChromeClient = webChromeClient
}
Expand Down Expand Up @@ -611,6 +614,17 @@ class RNCWebViewManagerImpl(private val newArch: Boolean = false) {
}
}

fun setMediaCapturePermissionGrantType(viewWrapper: RNCWebViewWrapper, value: String?) {
val view = viewWrapper.webView
// Keep the value so it survives recreation of the WebChromeClient
// (eg. when mAllowsFullScreenVideo changes).
mMediaCapturePermissionGrantType = value
val client = view.webChromeClient
if (client != null && client is RNCWebChromeClient) {
client.setMediaCapturePermissionGrantType(value)
}
}

fun setMenuCustomItems(viewWrapper: RNCWebViewWrapper, value: ReadableArray?) {
val view = viewWrapper.webView
when (value) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ public void setAllowsProtectedMedia(RNCWebViewWrapper view, boolean value) {
mRNCWebViewManagerImpl.setAllowsProtectedMedia(view, value);
}

@Override
@ReactProp(name = "mediaCapturePermissionGrantType")
public void setMediaCapturePermissionGrantType(RNCWebViewWrapper view, @Nullable String value) {
mRNCWebViewManagerImpl.setMediaCapturePermissionGrantType(view, value);
}

@Override
@ReactProp(name = "androidLayerType")
public void setAndroidLayerType(RNCWebViewWrapper view, @Nullable String value) {
Expand Down Expand Up @@ -423,9 +429,6 @@ public void setTextInteractionEnabled(RNCWebViewWrapper view, boolean value) {}
@Override
public void setHasOnFileDownload(RNCWebViewWrapper view, boolean value) {}

@Override
public void setMediaCapturePermissionGrantType(RNCWebViewWrapper view, @Nullable String value) {}

@Override
public void setFraudulentWebsiteWarningEnabled(RNCWebViewWrapper view, boolean value) {}
/* !iOS PROPS - no implemented here */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ public void setAllowsProtectedMedia(RNCWebViewWrapper view, boolean value) {
mRNCWebViewManagerImpl.setAllowsProtectedMedia(view, value);
}

@ReactProp(name = "mediaCapturePermissionGrantType")
public void setMediaCapturePermissionGrantType(RNCWebViewWrapper view, @Nullable String value) {
mRNCWebViewManagerImpl.setMediaCapturePermissionGrantType(view, value);
}

@ReactProp(name = "androidLayerType")
public void setAndroidLayerType(RNCWebViewWrapper view, @Nullable String value) {
mRNCWebViewManagerImpl.setAndroidLayerType(view, value);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.reactnativecommunity.webview;

import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import android.webkit.PermissionRequest;

import org.junit.Test;

/**
* Unit tests for {@link RNCWebChromeClient#onPermissionRequest(PermissionRequest)} covering the
* Android enforcement of the {@code mediaCapturePermissionGrantType="deny"} prop.
*/
public class RNCWebChromeClientTest {

private RNCWebChromeClient createClient() {
RNCWebView webView = mock(RNCWebView.class);
return new RNCWebChromeClient(webView);
}

@Test
public void denyShortCircuitsPermissionRequest() {
RNCWebChromeClient client = createClient();
client.setMediaCapturePermissionGrantType("deny");

PermissionRequest request = mock(PermissionRequest.class);
client.onPermissionRequest(request);

// The request is denied immediately...
verify(request, times(1)).deny();
// ...and never reaches the resource-mapping / OS-permission / AlertDialog path,
// which always starts by reading the requested resources.
verify(request, never()).getResources();
verify(request, never()).grant(org.mockito.ArgumentMatchers.any());
}

@Test
public void nullGrantTypePreservesExistingPromptFlow() {
RNCWebChromeClient client = createClient();
// No grant type configured (default behavior).

PermissionRequest request = mock(PermissionRequest.class);
when(request.getResources()).thenReturn(new String[] {});

client.onPermissionRequest(request);

// It does NOT take the deny short-circuit: the existing flow always inspects
// the requested resources first.
verify(request, times(1)).getResources();
}

@Test
public void unknownGrantTypePreservesExistingPromptFlow() {
RNCWebChromeClient client = createClient();
// An unsupported value must preserve existing prompt behavior, not deny.
client.setMediaCapturePermissionGrantType("grant");

PermissionRequest request = mock(PermissionRequest.class);
when(request.getResources()).thenReturn(new String[] {});

client.onPermissionRequest(request);

verify(request, times(1)).getResources();
}
}
8 changes: 5 additions & 3 deletions docs/Reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -1537,9 +1537,11 @@ Possible values:

Note that a grant may still result in a prompt, for example if the user has never been prompted for the permission before.

| Type | Required | Platform |
| ------ | -------- | -------- |
| string | No | iOS |
On Android, only the `deny` value is enforced natively: when set, camera/microphone requests are denied immediately without showing a prompt or triggering an OS permission dialog. Any other value (including the default `prompt`) preserves the existing Android prompt behavior.

| Type | Required | Platform |
| ------ | -------- | ------------ |
| string | No | iOS, Android |

Example:

Expand Down
13 changes: 13 additions & 0 deletions src/WebViewTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,9 @@ export interface IOSWebViewProps extends WebViewSharedProps {
* This property specifies how to handle media capture permission requests.
* Defaults to `prompt`, resulting in the user being prompted repeatedly.
* Available on iOS 15 and later.
*
* Note: Android only enforces the `deny` value natively (see
* `AndroidWebViewProps`); other values are iOS-only.
*/
mediaCapturePermissionGrantType?: MediaCapturePermissionGrantType;

Expand Down Expand Up @@ -1163,6 +1166,16 @@ export interface AndroidWebViewProps extends WebViewSharedProps {
*/
allowsProtectedMedia?: boolean;

/**
* This property specifies how to handle media capture permission requests.
* On Android, only `deny` is enforced natively: when set, camera/microphone
* requests are denied without showing a prompt or triggering an OS permission
* dialog. Any other value (including the default `prompt`) preserves the
* existing Android prompt behavior.
* @platform android
*/
mediaCapturePermissionGrantType?: MediaCapturePermissionGrantType;

/**
* Function that is invoked when the `WebView` receives an SSL error for a sub-resource.
*
Expand Down
Loading