diff --git a/core/src/main/kotlin/dev/hotwire/core/files/delegates/WebViewPermissionDelegate.kt b/core/src/main/kotlin/dev/hotwire/core/files/delegates/WebViewPermissionDelegate.kt new file mode 100644 index 00000000..4eeb47ac --- /dev/null +++ b/core/src/main/kotlin/dev/hotwire/core/files/delegates/WebViewPermissionDelegate.kt @@ -0,0 +1,166 @@ +package dev.hotwire.core.files.delegates + +import android.Manifest.permission.CAMERA +import android.Manifest.permission.MODIFY_AUDIO_SETTINGS +import android.Manifest.permission.RECORD_AUDIO +import android.content.Context +import android.content.pm.PackageManager +import android.webkit.PermissionRequest +import androidx.core.content.ContextCompat +import androidx.core.content.PermissionChecker +import dev.hotwire.core.files.util.HOTWIRE_REQUEST_CODE_WEBVIEW_PERMISSION +import dev.hotwire.core.logging.logError +import dev.hotwire.core.logging.logWarning +import dev.hotwire.core.turbo.session.Session + +/** + * Handles WebView-issued [PermissionRequest]s for media-capture resources + * (`RESOURCE_AUDIO_CAPTURE` and `RESOURCE_VIDEO_CAPTURE`). Requests for any + * other resource are denied by default. + * + * Manifest requirements for the host app: + * + * - `RESOURCE_AUDIO_CAPTURE` requires both `android.permission.RECORD_AUDIO` + * and `android.permission.MODIFY_AUDIO_SETTINGS`. Only `RECORD_AUDIO` is + * runtime-requested; `MODIFY_AUDIO_SETTINGS` is a normal (install-time) + * permission, but the Chromium WebView's audio pipeline requires it to + * select an audio device — without it, `getUserMedia({ audio: true })` + * fails with `Unable to select communication device!` even after + * `RECORD_AUDIO` is granted. + * - `RESOURCE_VIDEO_CAPTURE` requires `android.permission.CAMERA`. + * + * If a requested resource's manifest permissions are not all declared, the + * entire request is denied so the page sees a `NotAllowedError` and can react + * appropriately (and a warning is logged to surface the missing declaration). + */ +class WebViewPermissionDelegate(private val session: Session) { + private val context: Context = session.context + + private var pendingRequest: PermissionRequest? = null + + fun onRequest(request: PermissionRequest) { + val requestedResources = request.resources?.toList().orEmpty() + val supportedResources = requestedResources.filter { it in SUPPORTED_RESOURCES } + + if (supportedResources.isEmpty() || supportedResources.size != requestedResources.size) { + // Either no recognized resource was requested or the request mixes recognized and unrecognized resources. + request.deny() + return + } + + val manifestPermissions = supportedResources.flatMap { it.requiredManifestPermissions() }.distinct() + val undeclared = manifestPermissions.filterNot { isDeclaredInManifest(it) } + if (undeclared.isNotEmpty()) { + logWarning( + "webViewPermissionNotDeclared", + "Permission(s) ${undeclared.joinToString()} are not declared in the host " + + "app's AndroidManifest.xml. Add them via to enable " + + "the corresponding WebView media-capture resource(s)." + ) + request.deny() + return + } + + // Only dangerous-level permissions need a runtime grant. Normal-level + // permissions like MODIFY_AUDIO_SETTINGS are granted automatically at + // install time once declared in the manifest. + val runtimeNeeded = manifestPermissions.filter { it in RUNTIME_GRANT_PERMISSIONS && !isGranted(it) } + if (runtimeNeeded.isEmpty()) { + request.grant(supportedResources.toTypedArray()) + return + } + + // Replace any previously-held request before storing the new one so + // the WebView always sees a grant or deny — never an orphaned request + // that's silently dropped because it was overwritten. + pendingRequest?.deny() + pendingRequest = request + startPermissionRequest(runtimeNeeded) + } + + /** + * Forwarded from [android.webkit.WebChromeClient.onPermissionRequestCanceled]. + * Clears our pending state if it matches the canceled request so we don't + * later call grant/deny on a request that the WebView has already given up + * on (e.g. the user navigated away or the page was reloaded mid-prompt). + */ + fun onCancel(request: PermissionRequest) { + if (pendingRequest === request) { + pendingRequest = null + } + } + + fun onActivityResult(grantResults: Map) { + val request = pendingRequest ?: return + pendingRequest = null + + val resources = request.resources?.toList().orEmpty() + val manifestPermissions = resources.flatMap { it.requiredManifestPermissions() }.distinct() + val allGranted = manifestPermissions.all { permission -> + grantResults[permission] == true || isGranted(permission) + } + + if (allGranted) { + request.grant(resources.toTypedArray()) + } else { + request.deny() + } + } + + private fun startPermissionRequest(permissions: List) { + val destination = session.currentVisit?.callback?.visitDestination() + val resultLauncher = destination?.activityMultiplePermissionsResultLauncher( + HOTWIRE_REQUEST_CODE_WEBVIEW_PERMISSION + ) + + if (resultLauncher == null) { + pendingRequest?.deny() + pendingRequest = null + return + } + + try { + resultLauncher.launch(permissions.toTypedArray()) + } catch (e: Exception) { + logError("startWebViewPermissionError", e) + pendingRequest?.deny() + pendingRequest = null + } + } + + private fun isGranted(permission: String): Boolean { + return ContextCompat.checkSelfPermission(context, permission) == + PermissionChecker.PERMISSION_GRANTED + } + + private fun isDeclaredInManifest(permission: String): Boolean { + return manifestPermissions().contains(permission) + } + + private fun manifestPermissions(): Array { + return try { + val packageInfo = context.packageManager.getPackageInfo( + context.packageName, + PackageManager.GET_PERMISSIONS + ) + packageInfo.requestedPermissions ?: emptyArray() + } catch (e: PackageManager.NameNotFoundException) { + logError("manifestPermissionsNotAvailable", e) + emptyArray() + } + } + + private fun String.requiredManifestPermissions(): List = when (this) { + PermissionRequest.RESOURCE_AUDIO_CAPTURE -> listOf(RECORD_AUDIO, MODIFY_AUDIO_SETTINGS) + PermissionRequest.RESOURCE_VIDEO_CAPTURE -> listOf(CAMERA) + else -> error("Unsupported WebView resource: $this") + } + + private companion object { + private val SUPPORTED_RESOURCES = setOf( + PermissionRequest.RESOURCE_AUDIO_CAPTURE, + PermissionRequest.RESOURCE_VIDEO_CAPTURE, + ) + private val RUNTIME_GRANT_PERMISSIONS = setOf(RECORD_AUDIO, CAMERA) + } +} diff --git a/core/src/main/kotlin/dev/hotwire/core/files/util/FileConstants.kt b/core/src/main/kotlin/dev/hotwire/core/files/util/FileConstants.kt index 1a69c4b4..b3eb246f 100644 --- a/core/src/main/kotlin/dev/hotwire/core/files/util/FileConstants.kt +++ b/core/src/main/kotlin/dev/hotwire/core/files/util/FileConstants.kt @@ -5,3 +5,4 @@ const val HOTWIRE_REQUEST_CODE_FILES = 37 // Permission activity launcher request codes const val HOTWIRE_REQUEST_CODE_GEOLOCATION_PERMISSION = 3737 +const val HOTWIRE_REQUEST_CODE_WEBVIEW_PERMISSION = 3738 diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/session/Session.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/session/Session.kt index 7cb9d91f..7422151b 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/session/Session.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/session/Session.kt @@ -23,6 +23,7 @@ import androidx.webkit.WebViewFeature.isFeatureSupported import dev.hotwire.core.config.Hotwire import dev.hotwire.core.files.delegates.FileChooserDelegate import dev.hotwire.core.files.delegates.GeolocationPermissionDelegate +import dev.hotwire.core.files.delegates.WebViewPermissionDelegate import dev.hotwire.core.logging.logEvent import dev.hotwire.core.logging.logWarning import dev.hotwire.core.turbo.errors.HttpError @@ -103,6 +104,12 @@ class Session( */ val geolocationPermissionDelegate = GeolocationPermissionDelegate(this) + /** + * The delegate that handles WebView-issued [android.webkit.PermissionRequest]s + * for media-capture resources. Currently audio-only. + */ + val webViewPermissionDelegate = WebViewPermissionDelegate(this) + init { initializeWebView() HotwireHttpClient.enableCachingWith(context) diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/visit/VisitDestination.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/visit/VisitDestination.kt index 823a5f4c..49b85b2f 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/visit/VisitDestination.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/visit/VisitDestination.kt @@ -7,4 +7,14 @@ interface VisitDestination { fun isActive(): Boolean fun activityResultLauncher(requestCode: Int): ActivityResultLauncher? fun activityPermissionResultLauncher(requestCode: Int): ActivityResultLauncher? + + /** + * Returns a launcher capable of requesting multiple runtime permissions + * at once. Used by [dev.hotwire.core.files.delegates.WebViewPermissionDelegate] + * for media-capture requests that may include both audio and video. Default + * implementation returns `null`; concrete destinations should override. + */ + fun activityMultiplePermissionsResultLauncher( + requestCode: Int + ): ActivityResultLauncher>? = null } diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/webview/HotwireWebChromeClient.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/webview/HotwireWebChromeClient.kt index 04ebb7b3..3a83c1d8 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/webview/HotwireWebChromeClient.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/webview/HotwireWebChromeClient.kt @@ -4,6 +4,7 @@ import android.net.Uri import android.os.Message import android.webkit.GeolocationPermissions import android.webkit.JsResult +import android.webkit.PermissionRequest import android.webkit.ValueCallback import android.webkit.WebChromeClient import android.webkit.WebView @@ -96,4 +97,27 @@ open class HotwireWebChromeClient(val session: Session) : WebChromeClient() { ) { session.geolocationPermissionDelegate.onRequestPermission(origin, callback) } + + override fun onPermissionRequest(request: PermissionRequest) { + if (request.requestsMediaCapture()) { + session.webViewPermissionDelegate.onRequest(request) + } else { + super.onPermissionRequest(request) + } + } + + override fun onPermissionRequestCanceled(request: PermissionRequest) { + // Always forward the cancel; the delegate is a no-op when the request + // doesn't match the one it's currently tracking. + session.webViewPermissionDelegate.onCancel(request) + super.onPermissionRequestCanceled(request) + } + + private fun PermissionRequest.requestsMediaCapture(): Boolean { + val resources = resources ?: return false + return resources.isNotEmpty() && resources.all { resource -> + resource == PermissionRequest.RESOURCE_AUDIO_CAPTURE || + resource == PermissionRequest.RESOURCE_VIDEO_CAPTURE + } + } } diff --git a/core/src/test/kotlin/dev/hotwire/core/files/delegates/WebViewPermissionDelegateTest.kt b/core/src/test/kotlin/dev/hotwire/core/files/delegates/WebViewPermissionDelegateTest.kt new file mode 100644 index 00000000..2d89106f --- /dev/null +++ b/core/src/test/kotlin/dev/hotwire/core/files/delegates/WebViewPermissionDelegateTest.kt @@ -0,0 +1,248 @@ +package dev.hotwire.core.files.delegates + +import android.Manifest.permission.CAMERA +import android.Manifest.permission.MODIFY_AUDIO_SETTINGS +import android.Manifest.permission.RECORD_AUDIO +import android.app.Application +import android.content.Context +import android.os.Build +import android.webkit.PermissionRequest +import androidx.activity.result.ActivityResultLauncher +import androidx.appcompat.app.AppCompatActivity +import androidx.test.core.app.ApplicationProvider +import com.nhaarman.mockito_kotlin.whenever +import dev.hotwire.core.turbo.BaseRepositoryTest +import dev.hotwire.core.turbo.session.Session +import dev.hotwire.core.turbo.session.SessionCallback +import dev.hotwire.core.turbo.visit.Visit +import dev.hotwire.core.turbo.visit.VisitDestination +import dev.hotwire.core.turbo.visit.VisitOptions +import dev.hotwire.core.turbo.webview.HotwireWebView +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.MockitoAnnotations +import org.robolectric.Robolectric.buildActivity +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@ExperimentalCoroutinesApi +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.R]) +class WebViewPermissionDelegateTest : BaseRepositoryTest() { + @Mock + private lateinit var webView: HotwireWebView + private lateinit var activity: AppCompatActivity + private lateinit var context: Context + private lateinit var session: Session + + @Before + override fun setup() { + super.setup() + MockitoAnnotations.openMocks(this) + + activity = buildActivity(TurboTestActivity::class.java).get() + context = ApplicationProvider.getApplicationContext() + session = Session("test", activity, webView) + } + + @Test + fun `denies request that asks for an unsupported resource`() { + declareInManifest(RECORD_AUDIO) + val request = mockRequest("android.webkit.resource.MIDI_SYSEX") + + session.webViewPermissionDelegate.onRequest(request) + + verify(request).deny() + } + + @Test + fun `denies audio request when RECORD_AUDIO is not declared in manifest`() { + // Manifest declares neither audio nor video. + val request = mockRequest(PermissionRequest.RESOURCE_AUDIO_CAPTURE) + + session.webViewPermissionDelegate.onRequest(request) + + verify(request).deny() + } + + @Test + fun `denies audio request when MODIFY_AUDIO_SETTINGS is not declared in manifest`() { + // RECORD_AUDIO alone isn't enough — Chromium WebView needs + // MODIFY_AUDIO_SETTINGS to select the audio device. + declareInManifest(RECORD_AUDIO) + val request = mockRequest(PermissionRequest.RESOURCE_AUDIO_CAPTURE) + + session.webViewPermissionDelegate.onRequest(request) + + verify(request).deny() + } + + @Test + fun `denies video request when CAMERA is not declared in manifest`() { + val request = mockRequest(PermissionRequest.RESOURCE_VIDEO_CAPTURE) + + session.webViewPermissionDelegate.onRequest(request) + + verify(request).deny() + } + + @Test + fun `denies audio + video request when only audio permissions are declared`() { + declareInManifest(RECORD_AUDIO, MODIFY_AUDIO_SETTINGS) + // CAMERA is missing — the whole request is denied so the page sees a + // NotAllowedError and can fall back to audio-only. + val request = mockRequest( + PermissionRequest.RESOURCE_AUDIO_CAPTURE, + PermissionRequest.RESOURCE_VIDEO_CAPTURE, + ) + + session.webViewPermissionDelegate.onRequest(request) + + verify(request).deny() + } + + @Test + fun `grants audio when permission is already granted`() { + declareInManifest(RECORD_AUDIO, MODIFY_AUDIO_SETTINGS) + grantRuntimePermissions(RECORD_AUDIO, MODIFY_AUDIO_SETTINGS) + val request = mockRequest(PermissionRequest.RESOURCE_AUDIO_CAPTURE) + + session.webViewPermissionDelegate.onRequest(request) + + verify(request).grant(arrayOf(PermissionRequest.RESOURCE_AUDIO_CAPTURE)) + } + + @Test + fun `grants video when permission is already granted`() { + declareInManifest(CAMERA) + grantRuntimePermissions(CAMERA) + val request = mockRequest(PermissionRequest.RESOURCE_VIDEO_CAPTURE) + + session.webViewPermissionDelegate.onRequest(request) + + verify(request).grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) + } + + @Test + fun `grants audio + video when all permissions are already granted`() { + declareInManifest(RECORD_AUDIO, MODIFY_AUDIO_SETTINGS, CAMERA) + grantRuntimePermissions(RECORD_AUDIO, MODIFY_AUDIO_SETTINGS, CAMERA) + val resources = arrayOf( + PermissionRequest.RESOURCE_AUDIO_CAPTURE, + PermissionRequest.RESOURCE_VIDEO_CAPTURE, + ) + val request = mockRequest(*resources) + + session.webViewPermissionDelegate.onRequest(request) + + verify(request).grant(resources) + } + + @Test + fun `onActivityResult is a no-op when no request is held`() { + session.webViewPermissionDelegate.onActivityResult(emptyMap()) + session.webViewPermissionDelegate.onActivityResult(mapOf(RECORD_AUDIO to true)) + } + + @Test + fun `a second pending request denies the first to avoid orphaning it`() { + declareInManifest(RECORD_AUDIO, MODIFY_AUDIO_SETTINGS) + wireDestinationWithLauncher() + // Permission not yet granted at runtime, so each request is held as + // pending after launching the system prompt. The second request should + // explicitly deny the first so the WebView always gets a verdict. + val first = mockRequest(PermissionRequest.RESOURCE_AUDIO_CAPTURE) + val second = mockRequest(PermissionRequest.RESOURCE_AUDIO_CAPTURE) + + session.webViewPermissionDelegate.onRequest(first) + session.webViewPermissionDelegate.onRequest(second) + + verify(first).deny() + } + + @Test + fun `onCancel clears matching pending request so onActivityResult is a no-op`() { + declareInManifest(RECORD_AUDIO, MODIFY_AUDIO_SETTINGS) + wireDestinationWithLauncher() + val request = mockRequest(PermissionRequest.RESOURCE_AUDIO_CAPTURE) + + session.webViewPermissionDelegate.onRequest(request) + session.webViewPermissionDelegate.onCancel(request) + // After the cancel, the system permission dialog might still resolve; + // the result must be ignored rather than applied to the canceled + // request. + session.webViewPermissionDelegate.onActivityResult(mapOf(RECORD_AUDIO to true)) + + verify(request, org.mockito.Mockito.never()).grant(arrayOf(PermissionRequest.RESOURCE_AUDIO_CAPTURE)) + verify(request, org.mockito.Mockito.never()).deny() + } + + @Test + fun `onCancel ignores a request that is not currently pending`() { + declareInManifest(RECORD_AUDIO, MODIFY_AUDIO_SETTINGS) + // In production MODIFY_AUDIO_SETTINGS is auto-granted at install since + // it is a normal-level permission; Robolectric requires the explicit + // grant for the post-result isGranted() check to return true. + grantRuntimePermissions(MODIFY_AUDIO_SETTINGS) + wireDestinationWithLauncher() + val pending = mockRequest(PermissionRequest.RESOURCE_AUDIO_CAPTURE) + val unrelated = mockRequest(PermissionRequest.RESOURCE_AUDIO_CAPTURE) + + session.webViewPermissionDelegate.onRequest(pending) + session.webViewPermissionDelegate.onCancel(unrelated) + // The pending request is still tracked; resolving via onActivityResult + // with a granted permission should grant the original request. + session.webViewPermissionDelegate.onActivityResult(mapOf(RECORD_AUDIO to true)) + + verify(pending).grant(arrayOf(PermissionRequest.RESOURCE_AUDIO_CAPTURE)) + } + + private fun declareInManifest(vararg permissions: String) { + val packageInfo = shadowOf(context.packageManager) + .getInternalMutablePackageInfo(context.packageName) + val existing = packageInfo.requestedPermissions ?: emptyArray() + packageInfo.requestedPermissions = (existing + permissions).distinct().toTypedArray() + } + + private fun grantRuntimePermissions(vararg permissions: String) { + shadowOf(context.applicationContext as Application).grantPermissions(*permissions) + } + + @Suppress("UNCHECKED_CAST") + private fun wireDestinationWithLauncher() { + // Provide a no-op launcher so onRequest holds the request as pending + // instead of denying it via the launcher-not-available branch. + val launcher = mock(ActivityResultLauncher::class.java) as ActivityResultLauncher> + val visitDestination = object : VisitDestination { + override fun isActive() = true + override fun activityResultLauncher(requestCode: Int) = null + override fun activityPermissionResultLauncher(requestCode: Int) = null + override fun activityMultiplePermissionsResultLauncher( + requestCode: Int + ): ActivityResultLauncher>? = launcher + } + val callback = mock(SessionCallback::class.java) + whenever(callback.visitDestination()).thenReturn(visitDestination) + session.currentVisit = Visit( + location = baseUrl(), + destinationIdentifier = 1, + restoreWithCachedSnapshot = false, + reload = false, + callback = callback, + identifier = "", + options = VisitOptions(), + ) + } + + private fun mockRequest(vararg resources: String): PermissionRequest { + val request = mock(PermissionRequest::class.java) + whenever(request.resources).thenReturn(resources) + return request + } +} diff --git a/navigation-fragments/src/main/java/dev/hotwire/navigation/destinations/HotwireDestination.kt b/navigation-fragments/src/main/java/dev/hotwire/navigation/destinations/HotwireDestination.kt index 34dd47a6..fa0e2791 100644 --- a/navigation-fragments/src/main/java/dev/hotwire/navigation/destinations/HotwireDestination.kt +++ b/navigation-fragments/src/main/java/dev/hotwire/navigation/destinations/HotwireDestination.kt @@ -170,6 +170,23 @@ interface HotwireDestination : BridgeDestination { return null } + /** + * Gets a registered `ActivityResultContracts.RequestMultiplePermissions` activity result + * launcher instance for the given `requestCode`. + * + * Override to provide your own [androidx.activity.result.ActivityResultLauncher] + * instances. If your app doesn't have a matching `requestCode`, you must call + * `super.activityMultiplePermissionsResultLauncher(requestCode)` to give the + * library an opportunity to provide a matching result launcher. + * + * @param requestCode The request code for the corresponding result launcher. + */ + fun activityMultiplePermissionsResultLauncher( + requestCode: Int + ): ActivityResultLauncher>? { + return null + } + fun prepareNavigation(onReady: () -> Unit) override fun bridgeWebViewIsReady(): Boolean { diff --git a/navigation-fragments/src/main/java/dev/hotwire/navigation/fragments/HotwireWebBottomSheetFragment.kt b/navigation-fragments/src/main/java/dev/hotwire/navigation/fragments/HotwireWebBottomSheetFragment.kt index 06e19974..18110e99 100644 --- a/navigation-fragments/src/main/java/dev/hotwire/navigation/fragments/HotwireWebBottomSheetFragment.kt +++ b/navigation-fragments/src/main/java/dev/hotwire/navigation/fragments/HotwireWebBottomSheetFragment.kt @@ -14,6 +14,7 @@ import dev.hotwire.core.bridge.BridgeComponentFragmentLifecycle import dev.hotwire.core.bridge.BridgeDelegate import dev.hotwire.core.files.util.HOTWIRE_REQUEST_CODE_FILES import dev.hotwire.core.files.util.HOTWIRE_REQUEST_CODE_GEOLOCATION_PERMISSION +import dev.hotwire.core.files.util.HOTWIRE_REQUEST_CODE_WEBVIEW_PERMISSION import dev.hotwire.core.turbo.errors.VisitError import dev.hotwire.core.turbo.webview.HotwireWebChromeClient import dev.hotwire.core.turbo.webview.HotwireWebView @@ -81,6 +82,15 @@ open class HotwireWebBottomSheetFragment : HotwireBottomSheetFragment(), Hotwire } } + override fun activityMultiplePermissionsResultLauncher( + requestCode: Int + ): ActivityResultLauncher>? { + return when (requestCode) { + HOTWIRE_REQUEST_CODE_WEBVIEW_PERMISSION -> webDelegate.webViewPermissionResultLauncher + else -> null + } + } + override fun onBridgeComponentInitialized(component: BridgeComponent<*>) { super.onBridgeComponentInitialized(component) diff --git a/navigation-fragments/src/main/java/dev/hotwire/navigation/fragments/HotwireWebFragment.kt b/navigation-fragments/src/main/java/dev/hotwire/navigation/fragments/HotwireWebFragment.kt index df62462b..791bf430 100644 --- a/navigation-fragments/src/main/java/dev/hotwire/navigation/fragments/HotwireWebFragment.kt +++ b/navigation-fragments/src/main/java/dev/hotwire/navigation/fragments/HotwireWebFragment.kt @@ -13,6 +13,7 @@ import dev.hotwire.core.bridge.BridgeComponentFragmentLifecycle import dev.hotwire.core.bridge.BridgeDelegate import dev.hotwire.core.files.util.HOTWIRE_REQUEST_CODE_FILES import dev.hotwire.core.files.util.HOTWIRE_REQUEST_CODE_GEOLOCATION_PERMISSION +import dev.hotwire.core.files.util.HOTWIRE_REQUEST_CODE_WEBVIEW_PERMISSION import dev.hotwire.core.turbo.errors.VisitError import dev.hotwire.core.turbo.webview.HotwireWebChromeClient import dev.hotwire.core.turbo.webview.HotwireWebView @@ -118,6 +119,15 @@ open class HotwireWebFragment : HotwireFragment(), HotwireWebFragmentCallback { } } + override fun activityMultiplePermissionsResultLauncher( + requestCode: Int + ): ActivityResultLauncher>? { + return when (requestCode) { + HOTWIRE_REQUEST_CODE_WEBVIEW_PERMISSION -> webDelegate.webViewPermissionResultLauncher + else -> null + } + } + override fun onBridgeComponentInitialized(component: BridgeComponent<*>) { super.onBridgeComponentInitialized(component) diff --git a/navigation-fragments/src/main/java/dev/hotwire/navigation/fragments/HotwireWebFragmentDelegate.kt b/navigation-fragments/src/main/java/dev/hotwire/navigation/fragments/HotwireWebFragmentDelegate.kt index 2668b779..ba9a9a83 100644 --- a/navigation-fragments/src/main/java/dev/hotwire/navigation/fragments/HotwireWebFragmentDelegate.kt +++ b/navigation-fragments/src/main/java/dev/hotwire/navigation/fragments/HotwireWebFragmentDelegate.kt @@ -3,6 +3,7 @@ package dev.hotwire.navigation.fragments import android.content.Intent import android.webkit.HttpAuthHandler import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions import androidx.activity.result.contract.ActivityResultContracts.RequestPermission import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.lifecycle.Lifecycle.State.STARTED @@ -64,6 +65,13 @@ internal class HotwireWebFragmentDelegate( */ val geoLocationPermissionResultLauncher = registerGeolocationPermissionLauncher() + /** + * The activity result launcher that handles WebView-issued + * [android.webkit.PermissionRequest]s for media-capture resources + * (audio and/or video). + */ + val webViewPermissionResultLauncher = registerWebViewPermissionLauncher() + fun prepareNavigation(onReady: () -> Unit) { session.removeCallback(this) detachWebView(onReady) @@ -183,6 +191,12 @@ internal class HotwireWebFragmentDelegate( return navDestination.activityPermissionResultLauncher(requestCode) } + override fun activityMultiplePermissionsResultLauncher( + requestCode: Int + ): ActivityResultLauncher>? { + return navDestination.activityMultiplePermissionsResultLauncher(requestCode) + } + // ----------------------------------------------------------------------- // SessionCallback interface // ----------------------------------------------------------------------- @@ -381,6 +395,12 @@ internal class HotwireWebFragmentDelegate( } } + private fun registerWebViewPermissionLauncher(): ActivityResultLauncher> { + return navDestination.fragment.registerForActivityResult(RequestMultiplePermissions()) { results -> + session.webViewPermissionDelegate.onActivityResult(results) + } + } + private fun visit(location: String, restoreWithCachedSnapshot: Boolean, reload: Boolean) { val restore = restoreWithCachedSnapshot && !reload val options = when {