diff --git a/changelog.d/6149.bugfix b/changelog.d/6149.bugfix new file mode 100644 index 0000000000..c255a8daee --- /dev/null +++ b/changelog.d/6149.bugfix @@ -0,0 +1 @@ +Make widget web view request system permissions for camera and microphone (PSF-1061) diff --git a/vector/src/main/java/im/vector/app/features/webview/WebChromeEventListener.kt b/vector/src/main/java/im/vector/app/features/webview/WebChromeEventListener.kt new file mode 100644 index 0000000000..d4bd2be186 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/webview/WebChromeEventListener.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.webview + +import android.webkit.PermissionRequest + +interface WebChromeEventListener { + + /** + * Triggered when the web view requests permissions. + * + * @param request The permission request. + */ + fun onPermissionRequest(request: PermissionRequest) +} diff --git a/vector/src/main/java/im/vector/app/features/webview/WebEventListener.kt b/vector/src/main/java/im/vector/app/features/webview/WebEventListener.kt new file mode 100644 index 0000000000..d4bf5b898b --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/webview/WebEventListener.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.webview + +interface WebEventListener : WebViewEventListener, WebChromeEventListener diff --git a/vector/src/main/java/im/vector/app/features/widgets/WidgetFragment.kt b/vector/src/main/java/im/vector/app/features/widgets/WidgetFragment.kt index cd2a4dcdf4..0f399d7c45 100644 --- a/vector/src/main/java/im/vector/app/features/widgets/WidgetFragment.kt +++ b/vector/src/main/java/im/vector/app/features/widgets/WidgetFragment.kt @@ -26,6 +26,8 @@ import android.view.Menu import android.view.MenuItem import android.view.View import android.view.ViewGroup +import android.webkit.PermissionRequest +import androidx.activity.result.contract.ActivityResultContracts import androidx.core.view.isInvisible import androidx.core.view.isVisible import com.airbnb.mvrx.Fail @@ -42,7 +44,8 @@ import im.vector.app.core.platform.OnBackPressed import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.utils.openUrlInExternalBrowser import im.vector.app.databinding.FragmentRoomWidgetBinding -import im.vector.app.features.webview.WebViewEventListener +import im.vector.app.features.webview.WebEventListener +import im.vector.app.features.widgets.webview.WebviewPermissionUtils import im.vector.app.features.widgets.webview.clearAfterWidget import im.vector.app.features.widgets.webview.setupForWidget import kotlinx.parcelize.Parcelize @@ -60,9 +63,11 @@ data class WidgetArgs( val urlParams: Map = emptyMap() ) : Parcelable -class WidgetFragment @Inject constructor() : +class WidgetFragment @Inject constructor( + private val permissionUtils: WebviewPermissionUtils +) : VectorBaseFragment(), - WebViewEventListener, + WebEventListener, OnBackPressed { private val fragmentArgs: WidgetArgs by args() @@ -271,6 +276,20 @@ class WidgetFragment @Inject constructor() : viewModel.handle(WidgetAction.OnWebViewLoadingError(url, true, errorCode, description)) } + private val permissionResultLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> + permissionUtils.onPermissionResult(result) + } + + override fun onPermissionRequest(request: PermissionRequest) { + permissionUtils.promptForPermissions( + title = R.string.room_widget_resource_permission_title, + request = request, + context = requireContext(), + activity = requireActivity(), + activityResultLauncher = permissionResultLauncher + ) + } + private fun displayTerms(displayTerms: WidgetViewEvents.DisplayTerms) { navigator.openTerms( context = requireContext(), diff --git a/vector/src/main/java/im/vector/app/features/widgets/webview/WebviewPermissionUtils.kt b/vector/src/main/java/im/vector/app/features/widgets/webview/WebviewPermissionUtils.kt index 12b58cc208..1be2b0896a 100644 --- a/vector/src/main/java/im/vector/app/features/widgets/webview/WebviewPermissionUtils.kt +++ b/vector/src/main/java/im/vector/app/features/widgets/webview/WebviewPermissionUtils.kt @@ -15,17 +15,31 @@ */ package im.vector.app.features.widgets.webview -import android.annotation.SuppressLint +import android.Manifest import android.content.Context import android.webkit.PermissionRequest +import androidx.activity.result.ActivityResultLauncher import androidx.annotation.StringRes +import androidx.annotation.VisibleForTesting +import androidx.fragment.app.FragmentActivity import com.google.android.material.dialog.MaterialAlertDialogBuilder import im.vector.app.R +import im.vector.app.core.utils.checkPermissions +import java.lang.NullPointerException +import javax.inject.Inject -object WebviewPermissionUtils { +class WebviewPermissionUtils @Inject constructor() { - @SuppressLint("NewApi") - fun promptForPermissions(@StringRes title: Int, request: PermissionRequest, context: Context) { + private var permissionRequest: PermissionRequest? = null + private var selectedPermissions = listOf() + + fun promptForPermissions( + @StringRes title: Int, + request: PermissionRequest, + context: Context, + activity: FragmentActivity, + activityResultLauncher: ActivityResultLauncher> + ) { val allowedPermissions = request.resources.map { it to false }.toMutableList() @@ -37,9 +51,21 @@ object WebviewPermissionUtils { allowedPermissions[which] = allowedPermissions[which].first to isChecked } .setPositiveButton(R.string.room_widget_resource_grant_permission) { _, _ -> - request.grant(allowedPermissions.mapNotNull { perm -> + permissionRequest = request + selectedPermissions = allowedPermissions.mapNotNull { perm -> perm.first.takeIf { perm.second } - }.toTypedArray()) + } + + val requiredAndroidPermissions = selectedPermissions.mapNotNull { permission -> + webPermissionToAndroidPermission(permission) + } + + // When checkPermissions returns false, some of the required Android permissions will + // have to be requested and the flow completes asynchronously via onPermissionResult + if (checkPermissions(requiredAndroidPermissions, activity, activityResultLauncher)) { + request.grant(selectedPermissions.toTypedArray()) + reset() + } } .setNegativeButton(R.string.room_widget_resource_decline_permission) { _, _ -> request.deny() @@ -47,6 +73,34 @@ object WebviewPermissionUtils { .show() } + fun onPermissionResult(result: Map) { + if (permissionRequest == null) { + throw NullPointerException("permissionRequest was null! Make sure to call promptForPermissions first.") + } + val grantedPermissions = filterPermissionsToBeGranted(selectedPermissions, result) + if (grantedPermissions.isNotEmpty()) { + permissionRequest?.grant(grantedPermissions.toTypedArray()) + } else { + permissionRequest?.deny() + } + reset() + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + fun filterPermissionsToBeGranted(selectedWebPermissions: List, androidPermissionResult: Map): List { + return selectedWebPermissions.filter { webPermission -> + val androidPermission = webPermissionToAndroidPermission(webPermission) + ?: return@filter true // No corresponding Android permission exists + return@filter androidPermissionResult[androidPermission] + ?: return@filter true // Android permission already granted before + } + } + + private fun reset() { + permissionRequest = null + selectedPermissions = listOf() + } + private fun webPermissionToHumanReadable(permission: String, context: Context): String { return when (permission) { PermissionRequest.RESOURCE_AUDIO_CAPTURE -> context.getString(R.string.room_widget_webview_access_microphone) @@ -55,4 +109,12 @@ object WebviewPermissionUtils { else -> permission } } + + private fun webPermissionToAndroidPermission(permission: String): String? { + return when (permission) { + PermissionRequest.RESOURCE_AUDIO_CAPTURE -> Manifest.permission.RECORD_AUDIO + PermissionRequest.RESOURCE_VIDEO_CAPTURE -> Manifest.permission.CAMERA + else -> null + } + } } diff --git a/vector/src/main/java/im/vector/app/features/widgets/webview/WidgetWebView.kt b/vector/src/main/java/im/vector/app/features/widgets/webview/WidgetWebView.kt index 7147529e5f..0207987ca3 100644 --- a/vector/src/main/java/im/vector/app/features/widgets/webview/WidgetWebView.kt +++ b/vector/src/main/java/im/vector/app/features/widgets/webview/WidgetWebView.kt @@ -25,10 +25,10 @@ import android.webkit.WebView import im.vector.app.R import im.vector.app.features.themes.ThemeUtils import im.vector.app.features.webview.VectorWebViewClient -import im.vector.app.features.webview.WebViewEventListener +import im.vector.app.features.webview.WebEventListener @SuppressLint("NewApi") -fun WebView.setupForWidget(webViewEventListener: WebViewEventListener) { +fun WebView.setupForWidget(eventListener: WebEventListener) { // xml value seems ignored setBackgroundColor(ThemeUtils.getColor(context, R.attr.colorSurface)) @@ -59,10 +59,10 @@ fun WebView.setupForWidget(webViewEventListener: WebViewEventListener) { // Permission requests webChromeClient = object : WebChromeClient() { override fun onPermissionRequest(request: PermissionRequest) { - WebviewPermissionUtils.promptForPermissions(R.string.room_widget_resource_permission_title, request, context) + eventListener.onPermissionRequest(request) } } - webViewClient = VectorWebViewClient(webViewEventListener) + webViewClient = VectorWebViewClient(eventListener) val cookieManager = CookieManager.getInstance() cookieManager.setAcceptThirdPartyCookies(this, false) diff --git a/vector/src/test/java/im/vector/app/features/widgets/WebviewPermissionUtilsTest.kt b/vector/src/test/java/im/vector/app/features/widgets/WebviewPermissionUtilsTest.kt new file mode 100644 index 0000000000..359f52fc85 --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/widgets/WebviewPermissionUtilsTest.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.widgets + +import android.Manifest +import android.webkit.PermissionRequest +import im.vector.app.features.widgets.webview.WebviewPermissionUtils +import org.amshove.kluent.shouldBeEqualTo +import org.junit.FixMethodOrder +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.junit.runners.MethodSorters + +@RunWith(JUnit4::class) +@FixMethodOrder(MethodSorters.JVM) +class WebviewPermissionUtilsTest { + + private val utils = WebviewPermissionUtils() + + @Test + fun filterPermissionsToBeGranted_selectedAndGrantedNothing() { + val permissions = utils.filterPermissionsToBeGranted( + selectedWebPermissions = listOf(), + androidPermissionResult = mapOf()) + permissions shouldBeEqualTo listOf() + } + + @Test + fun filterPermissionsToBeGranted_selectedNothingGrantedCamera() { + val permissions = utils.filterPermissionsToBeGranted( + selectedWebPermissions = listOf(), + androidPermissionResult = mapOf(Manifest.permission.CAMERA to true)) + permissions shouldBeEqualTo listOf() + } + + @Test + fun filterPermissionsToBeGranted_selectedAndPreviouslyGrantedCamera() { + val permissions = utils.filterPermissionsToBeGranted( + selectedWebPermissions = listOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE), + androidPermissionResult = mapOf()) + permissions shouldBeEqualTo listOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE) + } + + @Test + fun filterPermissionsToBeGranted_selectedAndGrantedCamera() { + val permissions = utils.filterPermissionsToBeGranted( + selectedWebPermissions = listOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE), + androidPermissionResult = mapOf(Manifest.permission.CAMERA to true)) + permissions shouldBeEqualTo listOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE) + } + + @Test + fun filterPermissionsToBeGranted_selectedAndDeniedCamera() { + val permissions = utils.filterPermissionsToBeGranted( + selectedWebPermissions = listOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE), + androidPermissionResult = mapOf(Manifest.permission.CAMERA to false)) + permissions shouldBeEqualTo listOf() + } + + @Test + fun filterPermissionsToBeGranted_selectedProtectedMediaGrantedNothing() { + val permissions = utils.filterPermissionsToBeGranted( + selectedWebPermissions = listOf(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID), + androidPermissionResult = mapOf(Manifest.permission.CAMERA to false)) + permissions shouldBeEqualTo listOf(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID) + } +}