Merge pull request #6149 from vector-im/johannes/widget-system-permissions

Make widget web view request system permissions for camera and microphone (PSF-1061)
This commit is contained in:
Johannes Marbach 2022-05-31 10:54:45 +02:00 committed by GitHub
commit 7dd5b801bb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 225 additions and 13 deletions

1
changelog.d/6149.bugfix Normal file
View file

@ -0,0 +1 @@
Make widget web view request system permissions for camera and microphone (PSF-1061)

View file

@ -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)
}

View file

@ -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

View file

@ -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<String, String> = emptyMap()
) : Parcelable
class WidgetFragment @Inject constructor() :
class WidgetFragment @Inject constructor(
private val permissionUtils: WebviewPermissionUtils
) :
VectorBaseFragment<FragmentRoomWidgetBinding>(),
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(),

View file

@ -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<String>()
fun promptForPermissions(
@StringRes title: Int,
request: PermissionRequest,
context: Context,
activity: FragmentActivity,
activityResultLauncher: ActivityResultLauncher<Array<String>>
) {
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<String, Boolean>) {
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<String>, androidPermissionResult: Map<String, Boolean>): List<String> {
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
}
}
}

View file

@ -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)

View file

@ -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)
}
}