Skip to content

Commit d6b0e95

Browse files
authored
Merge pull request #217 from adamkobor/akobor/allow-protected-media-on-android
handle PermissionRequests in the WebChromeClient
2 parents 985beb5 + 06381d3 commit d6b0e95

6 files changed

Lines changed: 265 additions & 0 deletions

File tree

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,37 @@ Note that the HTML file should be put in the `resources/assets` folder of the sh
544544
It also supports external resources such as images, CSS, and JavaScript files on Android and iOS.
545545
Desktop support is coming soon.
546546

547+
## Handling permission requests on Android
548+
549+
There are 4 types of permissions that can be requested by the WebView on Android:
550+
551+
- RESOURCE_PROTECTED_MEDIA_ID
552+
- RESOURCE_MIDI_SYSEX
553+
- RESOURCE_AUDIO_CAPTURE
554+
- RESOURCE_VIDEO_CAPTURE
555+
556+
`RESOURCE_PROTECTED_MEDIA_ID` and `RESOURCE_MIDI_SYSEX` are special ones, because they don't have a
557+
native Android counterpart, so it's not possible to request a permission from the user to grant
558+
them transitively. Therefore, you configure the WebView to grant these permissions automatically,
559+
by setting the respective properties under `AndroidWebSettings` to true:
560+
561+
```kotlin
562+
webViewState.webSettings.apply {
563+
// ...
564+
androidWebSettings.apply {
565+
// Grants RESOURCE_PROTECTED_MEDIA_ID permission, default false
566+
allowProtectedMedia = true
567+
// Grants RESOURCE_MIDI_SYSEX permission, default false
568+
allowMidiSysexMessages = true
569+
}
570+
// ...
571+
}
572+
```
573+
574+
`RESOURCE_AUDIO_CAPTURE` and `RESOURCE_VIDEO_CAPTURE` are also handled internally by the WebView,
575+
but you need to make sure to explicitly ask for these permissions in your app. If user grants them,
576+
the WebView will be able to use them without any additional configuration.
577+
547578
## API
548579

549580
The complete API of this library is as follows:
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package com.kevinnzou.sample
2+
3+
import androidx.compose.foundation.layout.Column
4+
import androidx.compose.foundation.layout.fillMaxSize
5+
import androidx.compose.material.Icon
6+
import androidx.compose.material.IconButton
7+
import androidx.compose.material.MaterialTheme
8+
import androidx.compose.material.Text
9+
import androidx.compose.material.TopAppBar
10+
import androidx.compose.material.icons.Icons
11+
import androidx.compose.material.icons.automirrored.filled.ArrowBack
12+
import androidx.compose.runtime.Composable
13+
import androidx.compose.runtime.DisposableEffect
14+
import androidx.compose.ui.Modifier
15+
import androidx.navigation.NavHostController
16+
import com.multiplatform.webview.util.KLogSeverity
17+
import com.multiplatform.webview.web.WebView
18+
import com.multiplatform.webview.web.rememberWebViewNavigator
19+
import com.multiplatform.webview.web.rememberWebViewState
20+
21+
/**
22+
* Created By Adam Kobor on 2024/9/9
23+
*
24+
* Sample for DRM protected video handling in WebView.
25+
* `allowProtectedMedia` is set to true in the WebView settings, so EME APIs can be used to play DRM
26+
* protected content.
27+
*/
28+
@Composable
29+
internal fun DRMVideoSample(navHostController: NavHostController? = null) {
30+
val url = "https://bitmovin.com/demos/drm/"
31+
val state = rememberWebViewState(url = url)
32+
DisposableEffect(Unit) {
33+
state.webSettings.apply {
34+
logSeverity = KLogSeverity.Debug
35+
customUserAgentString =
36+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 11_1) AppleWebKit/625.20 (KHTML, like Gecko) Version/14.3.43 Safari/625.20"
37+
androidWebSettings.allowProtectedMedia = true
38+
}
39+
40+
onDispose { }
41+
}
42+
val navigator = rememberWebViewNavigator()
43+
44+
MaterialTheme {
45+
Column {
46+
TopAppBar(
47+
title = { Text(text = "DRM Video Sample") },
48+
navigationIcon = {
49+
IconButton(onClick = {
50+
if (navigator.canGoBack) {
51+
navigator.navigateBack()
52+
} else {
53+
navHostController?.popBackStack()
54+
}
55+
}) {
56+
Icon(
57+
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
58+
contentDescription = "Back",
59+
)
60+
}
61+
},
62+
)
63+
64+
WebView(
65+
state = state,
66+
modifier = Modifier.fillMaxSize(),
67+
navigator = navigator,
68+
)
69+
}
70+
}
71+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package com.kevinnzou.sample
2+
3+
import androidx.compose.foundation.layout.Column
4+
import androidx.compose.foundation.layout.fillMaxSize
5+
import androidx.compose.material.Icon
6+
import androidx.compose.material.IconButton
7+
import androidx.compose.material.MaterialTheme
8+
import androidx.compose.material.Text
9+
import androidx.compose.material.TopAppBar
10+
import androidx.compose.material.icons.Icons
11+
import androidx.compose.material.icons.automirrored.filled.ArrowBack
12+
import androidx.compose.runtime.Composable
13+
import androidx.compose.runtime.DisposableEffect
14+
import androidx.compose.ui.Modifier
15+
import androidx.navigation.NavHostController
16+
import com.multiplatform.webview.util.KLogSeverity
17+
import com.multiplatform.webview.web.WebView
18+
import com.multiplatform.webview.web.rememberWebViewNavigator
19+
import com.multiplatform.webview.web.rememberWebViewState
20+
21+
/**
22+
* Created By Adam Kobor On 2024/9/9
23+
*
24+
* Sample for MIDI SysEx handling in WebView.
25+
* `allowMidiSysexMessages` is set to true in the WebView settings, so MIDI SysEx messages can be
26+
* sent and received.
27+
*/
28+
@Composable
29+
internal fun MidiSample(navHostController: NavHostController? = null) {
30+
val url = "https://versioduo.com/webmidi-test/"
31+
val state = rememberWebViewState(url = url)
32+
DisposableEffect(Unit) {
33+
state.webSettings.apply {
34+
logSeverity = KLogSeverity.Debug
35+
customUserAgentString =
36+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 11_1) AppleWebKit/625.20 (KHTML, like Gecko) Version/14.3.43 Safari/625.20"
37+
androidWebSettings.allowMidiSysexMessages = true
38+
}
39+
40+
onDispose { }
41+
}
42+
val navigator = rememberWebViewNavigator()
43+
44+
MaterialTheme {
45+
Column {
46+
TopAppBar(
47+
title = { Text(text = "Midi SysEx Sample") },
48+
navigationIcon = {
49+
IconButton(onClick = {
50+
if (navigator.canGoBack) {
51+
navigator.navigateBack()
52+
} else {
53+
navHostController?.popBackStack()
54+
}
55+
}) {
56+
Icon(
57+
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
58+
contentDescription = "Back",
59+
)
60+
}
61+
},
62+
)
63+
64+
WebView(
65+
state = state,
66+
modifier = Modifier.fillMaxSize(),
67+
navigator = navigator,
68+
)
69+
}
70+
}
71+
}

sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/WebViewApp.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ internal fun WebViewApp() {
5050
composable("intercept") {
5151
InterceptRequestSample(controller)
5252
}
53+
composable("drm") {
54+
DRMVideoSample(controller)
55+
}
56+
composable("midi") {
57+
MidiSample(controller)
58+
}
5359
}
5460
}
5561

@@ -83,6 +89,18 @@ fun MainScreen(controller: NavController) {
8389
}) {
8490
Text("Intercept Request Sample", fontSize = 18.sp)
8591
}
92+
Spacer(modifier = Modifier.height(20.dp))
93+
Button(onClick = {
94+
controller.navigate("drm")
95+
}) {
96+
Text("DRM Video Sample", fontSize = 18.sp)
97+
}
98+
Spacer(modifier = Modifier.height(20.dp))
99+
Button(onClick = {
100+
controller.navigate("midi")
101+
}) {
102+
Text("Midi SysEx Sample", fontSize = 18.sp)
103+
}
86104
}
87105
}
88106

webview/src/androidMain/kotlin/com/multiplatform/webview/web/AccompanistWebView.kt

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package com.multiplatform.webview.web
22

33
import android.content.Context
4+
import android.content.pm.PackageManager
45
import android.content.res.Configuration
56
import android.graphics.Bitmap
67
import android.os.Build
78
import android.view.ViewGroup
9+
import android.webkit.PermissionRequest
810
import android.webkit.WebChromeClient
911
import android.webkit.WebResourceError
1012
import android.webkit.WebResourceRequest
@@ -19,6 +21,7 @@ import androidx.compose.runtime.rememberCoroutineScope
1921
import androidx.compose.ui.Modifier
2022
import androidx.compose.ui.graphics.toArgb
2123
import androidx.compose.ui.viewinterop.AndroidView
24+
import androidx.core.content.ContextCompat
2225
import androidx.webkit.WebSettingsCompat
2326
import androidx.webkit.WebViewFeature
2427
import com.multiplatform.webview.jsbridge.WebViewJsBridge
@@ -171,6 +174,7 @@ fun AccompanistWebView(
171174
this.restoreState(it)
172175
}
173176

177+
chromeClient.context = context
174178
webChromeClient = chromeClient
175179
webViewClient = client
176180

@@ -389,6 +393,8 @@ open class AccompanistWebViewClient : WebViewClient() {
389393
open class AccompanistWebChromeClient : WebChromeClient() {
390394
open lateinit var state: WebViewState
391395
internal set
396+
lateinit var context: Context
397+
internal set
392398
private var lastLoadedUrl = ""
393399

394400
override fun onReceivedTitle(
@@ -425,4 +431,59 @@ open class AccompanistWebChromeClient : WebChromeClient() {
425431
}
426432
lastLoadedUrl = view.url ?: ""
427433
}
434+
435+
override fun onPermissionRequest(request: PermissionRequest) {
436+
val grantedPermissions = mutableListOf<String>()
437+
KLogger.d { "onPermissionRequest received request for resources [${request.resources}]" }
438+
439+
request.resources.forEach { resource ->
440+
var androidPermission: String? = null
441+
442+
when (resource) {
443+
PermissionRequest.RESOURCE_AUDIO_CAPTURE -> {
444+
androidPermission = android.Manifest.permission.RECORD_AUDIO
445+
}
446+
447+
PermissionRequest.RESOURCE_MIDI_SYSEX -> {
448+
// MIDI sysex is only available on Android M and above
449+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
450+
if (state.webSettings.androidWebSettings.allowMidiSysexMessages) {
451+
grantedPermissions.add(PermissionRequest.RESOURCE_MIDI_SYSEX)
452+
}
453+
}
454+
}
455+
456+
PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID -> {
457+
if (state.webSettings.androidWebSettings.allowProtectedMedia) {
458+
grantedPermissions.add(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID)
459+
}
460+
}
461+
462+
PermissionRequest.RESOURCE_VIDEO_CAPTURE -> {
463+
androidPermission = android.Manifest.permission.CAMERA
464+
}
465+
}
466+
467+
if (androidPermission != null) {
468+
if (ContextCompat.checkSelfPermission(context, androidPermission) == PackageManager.PERMISSION_GRANTED) {
469+
grantedPermissions.add(resource)
470+
KLogger.d {
471+
"onPermissionRequest permission [$androidPermission] was already granted for resource [$resource]"
472+
}
473+
} else {
474+
KLogger.w {
475+
"onPermissionRequest didn't find already granted permission [$androidPermission] for resource [$resource]"
476+
}
477+
}
478+
}
479+
}
480+
481+
if (grantedPermissions.isNotEmpty()) {
482+
request.grant(grantedPermissions.toTypedArray())
483+
KLogger.d { "onPermissionRequest granted permissions: ${grantedPermissions.joinToString()}" }
484+
} else {
485+
request.deny()
486+
KLogger.d { "onPermissionRequest denied permissions: ${request.resources}" }
487+
}
488+
}
428489
}

webview/src/commonMain/kotlin/com/multiplatform/webview/setting/PlatformWebSettings.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,19 @@ sealed class PlatformWebSettings {
166166
* Whether the a user gesture is required to play media. The default is {@code true}.
167167
*/
168168
var mediaPlaybackRequiresUserGesture: Boolean = true,
169+
/**
170+
* Controls whether the `RESOURCE_PROTECTED_MEDIA_ID` permission requests should be
171+
* automatically granted or not. Necessary to be able to play back DRM protected media
172+
* inside the WebView.
173+
* The default is {@code false}.
174+
*/
175+
var allowProtectedMedia: Boolean = false,
176+
/**
177+
* Controls whether the `RESOURCE_MIDI_SYSEX` permission requests should be automatically
178+
* granted or not. The resource will allow sysex messages to be sent to or received from MIDI
179+
* devices. Available on API level 21 and above.
180+
*/
181+
var allowMidiSysexMessages: Boolean = false,
169182
/**
170183
* The Layer Type of the WebView.
171184
* Default is [LayerType.HARDWARE]

0 commit comments

Comments
 (0)