diff --git a/.changeset/age-378-android-deny-media-capture.md b/.changeset/age-378-android-deny-media-capture.md new file mode 100644 index 0000000000..16b44c5d88 --- /dev/null +++ b/.changeset/age-378-android-deny-media-capture.md @@ -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. diff --git a/android/build.gradle b/android/build.gradle index cb3476ad25..3c842f9959 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -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") @@ -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" } diff --git a/android/src/main/java/com/reactnativecommunity/webview/RNCWebChromeClient.java b/android/src/main/java/com/reactnativecommunity/webview/RNCWebChromeClient.java index 6dab254a9e..18142acf8a 100644 --- a/android/src/main/java/com/reactnativecommunity/webview/RNCWebChromeClient.java +++ b/android/src/main/java/com/reactnativecommunity/webview/RNCWebChromeClient.java @@ -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) { @@ -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<>(); @@ -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; } diff --git a/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewManagerImpl.kt b/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewManagerImpl.kt index ebfd70763c..8156b3c5ae 100644 --- a/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewManagerImpl.kt +++ b/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewManagerImpl.kt @@ -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 @@ -167,6 +168,7 @@ class RNCWebViewManagerImpl(private val newArch: Boolean = false) { } } webChromeClient.setAllowsProtectedMedia(mAllowsProtectedMedia); + webChromeClient.setMediaCapturePermissionGrantType(mMediaCapturePermissionGrantType); webChromeClient.setHasOnOpenWindowEvent(mHasOnOpenWindowEvent); webView.webChromeClient = webChromeClient } else { @@ -178,6 +180,7 @@ class RNCWebViewManagerImpl(private val newArch: Boolean = false) { } } webChromeClient.setAllowsProtectedMedia(mAllowsProtectedMedia); + webChromeClient.setMediaCapturePermissionGrantType(mMediaCapturePermissionGrantType); webChromeClient.setHasOnOpenWindowEvent(mHasOnOpenWindowEvent); webView.webChromeClient = webChromeClient } @@ -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) { diff --git a/android/src/newarch/com/reactnativecommunity/webview/RNCWebViewManager.java b/android/src/newarch/com/reactnativecommunity/webview/RNCWebViewManager.java index 53c40af53f..a682ac67d1 100644 --- a/android/src/newarch/com/reactnativecommunity/webview/RNCWebViewManager.java +++ b/android/src/newarch/com/reactnativecommunity/webview/RNCWebViewManager.java @@ -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) { @@ -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 */ diff --git a/android/src/oldarch/com/reactnativecommunity/webview/RNCWebViewManager.java b/android/src/oldarch/com/reactnativecommunity/webview/RNCWebViewManager.java index 27b7a03d3a..4882a866b3 100644 --- a/android/src/oldarch/com/reactnativecommunity/webview/RNCWebViewManager.java +++ b/android/src/oldarch/com/reactnativecommunity/webview/RNCWebViewManager.java @@ -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); diff --git a/android/src/test/java/com/reactnativecommunity/webview/RNCWebChromeClientTest.java b/android/src/test/java/com/reactnativecommunity/webview/RNCWebChromeClientTest.java new file mode 100644 index 0000000000..9b4daf37f0 --- /dev/null +++ b/android/src/test/java/com/reactnativecommunity/webview/RNCWebChromeClientTest.java @@ -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(); + } +} diff --git a/docs/Reference.md b/docs/Reference.md index 6d0b99acc0..150f8439e4 100644 --- a/docs/Reference.md +++ b/docs/Reference.md @@ -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: diff --git a/src/WebViewTypes.ts b/src/WebViewTypes.ts index 9eb2ab7256..71fc4dc019 100644 --- a/src/WebViewTypes.ts +++ b/src/WebViewTypes.ts @@ -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; @@ -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. *