diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 65c64e538..cb3cd9447 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -8,6 +8,7 @@
+
diff --git a/course/build.gradle b/course/build.gradle
index 227b52a0c..ecabd6d0e 100644
--- a/course/build.gradle
+++ b/course/build.gradle
@@ -70,6 +70,7 @@ dependencies {
implementation "androidx.media3:media3-ui:$media3_version"
implementation "androidx.media3:media3-cast:$media3_version"
implementation "me.saket.extendedspans:extendedspans:$extented_spans_version"
+ implementation "androidx.activity:activity-compose:$activity_compose_version"
testImplementation "junit:junit:$junit_version"
androidTestImplementation "androidx.test.ext:junit:$test_ext_version"
diff --git a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt
index 471918622..a3a6f7668 100644
--- a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt
+++ b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt
@@ -1,7 +1,9 @@
package org.openedx.course.presentation.unit.html
+import android.Manifest
import android.annotation.SuppressLint
import android.content.Intent
+import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.Bitmap
import android.net.Uri
@@ -10,12 +12,16 @@ import android.util.Log
import android.view.LayoutInflater
import android.view.ViewGroup
import android.webkit.JavascriptInterface
+import android.webkit.PermissionRequest
+import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
@@ -46,6 +52,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.zIndex
+import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import kotlinx.coroutines.launch
@@ -272,6 +279,28 @@ private fun HTMLContentView(
val isDarkTheme = isSystemInDarkTheme()
+ // Holds the in-flight PermissionRequest from the WebView until the Android permission
+ // dialog resolves. Storing it in a MutableState lets the launcher callback reach it
+ // without needing a mutable var captured inside the factory lambda.
+ val pendingWebPermissionRequest = remember { mutableStateOf(null) }
+
+ val permissionLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions()
+ ) { grants ->
+ val req = pendingWebPermissionRequest.value ?: return@rememberLauncherForActivityResult
+ pendingWebPermissionRequest.value = null
+ val grantedResources = req.resources.filter { resource ->
+ when (resource) {
+ PermissionRequest.RESOURCE_AUDIO_CAPTURE ->
+ grants[Manifest.permission.RECORD_AUDIO] == true
+ // Non-dangerous resources (e.g. RESOURCE_PROTECTED_MEDIA_ID) are granted
+ // without a corresponding Android permission.
+ else -> true
+ }
+ }.toTypedArray()
+ if (grantedResources.isNotEmpty()) req.grant(grantedResources) else req.deny()
+ }
+
AndroidView(
modifier = Modifier
.then(screenWidth)
@@ -362,6 +391,32 @@ private fun HTMLContentView(
super.onReceivedError(view, request, error)
}
}
+ // Grant camera/microphone access requested by web content (e.g. WebRTC, LTI
+ // tools, or real-time features). The corresponding Android permissions
+ // (RECORD_AUDIO, CAMERA) must also be declared in the manifest.
+ webChromeClient = object : WebChromeClient() {
+ override fun onPermissionRequest(request: PermissionRequest) {
+ if (pendingWebPermissionRequest.value != null) {
+ // Another request is already being resolved; deny immediately.
+ request.deny()
+ return
+ }
+ val androidPerms = buildList {
+ if (PermissionRequest.RESOURCE_AUDIO_CAPTURE in request.resources) {
+ add(Manifest.permission.RECORD_AUDIO)
+ }
+ }
+ if (androidPerms.isEmpty() || androidPerms.all {
+ ContextCompat.checkSelfPermission(context, it) ==
+ PackageManager.PERMISSION_GRANTED
+ }) {
+ request.grant(request.resources)
+ } else {
+ pendingWebPermissionRequest.value = request
+ permissionLauncher.launch(androidPerms.toTypedArray())
+ }
+ }
+ }
with(settings) {
javaScriptEnabled = true
loadWithOverviewMode = true
@@ -373,6 +428,9 @@ private fun HTMLContentView(
allowContentAccess = true
useWideViewPort = true
cacheMode = WebSettings.LOAD_NO_CACHE
+ // Allow audio and video elements to play without a prior user gesture.
+ // Required for LTI tools and real-time features that produce sound on load.
+ mediaPlaybackRequiresUserGesture = false
}
isVerticalScrollBarEnabled = false
isHorizontalScrollBarEnabled = false