Merge pull request #6616 from vector-im/feature/ons/element_call_widget

Support element call widget (PSG-627)
This commit is contained in:
Onuray Sahin 2022-07-22 19:03:03 +03:00 committed by GitHub
commit 75de805417
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 397 additions and 49 deletions

1
changelog.d/6616.feature Normal file
View file

@ -0,0 +1 @@
Support element call widget

View file

@ -28,7 +28,8 @@ private val DEFINED_TYPES by lazy {
WidgetType.StickerPicker,
WidgetType.Grafana,
WidgetType.Custom,
WidgetType.IntegrationManager
WidgetType.IntegrationManager,
WidgetType.ElementCall,
)
}
@ -47,6 +48,7 @@ sealed class WidgetType(open val preferred: String, open val legacy: String = pr
object Grafana : WidgetType("m.grafana")
object Custom : WidgetType("m.custom")
object IntegrationManager : WidgetType("m.integration_manager")
object ElementCall : WidgetType("io.element.call")
data class Fallback(override val preferred: String) : WidgetType(preferred)
fun matches(type: String): Boolean {

View file

@ -308,7 +308,8 @@
<activity android:name=".features.terms.ReviewTermsActivity" />
<activity
android:name=".features.widgets.WidgetActivity"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation" />
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
android:supportsPictureInPicture="true" />
<activity android:name=".features.pin.PinActivity" />
<activity android:name=".features.analytics.ui.consent.AnalyticsOptInActivity" />

View file

@ -0,0 +1,52 @@
/*
* 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.core.utils
import android.app.Activity
import android.content.pm.PackageManager
import android.webkit.PermissionRequest
import androidx.core.content.ContextCompat
import javax.inject.Inject
class CheckWebViewPermissionsUseCase @Inject constructor() {
/**
* Checks if required WebView permissions are already granted system level.
* @param activity the calling Activity that is requesting the permissions (or fragment parent)
* @param request WebView permission request of onPermissionRequest function
* @return true if WebView permissions are already granted, false otherwise
*/
fun execute(activity: Activity, request: PermissionRequest): Boolean {
return request.resources.all {
when (it) {
PermissionRequest.RESOURCE_AUDIO_CAPTURE -> {
PERMISSIONS_FOR_AUDIO_IP_CALL.all { permission ->
ContextCompat.checkSelfPermission(activity.applicationContext, permission) == PackageManager.PERMISSION_GRANTED
}
}
PermissionRequest.RESOURCE_VIDEO_CAPTURE -> {
PERMISSIONS_FOR_VIDEO_IP_CALL.all { permission ->
ContextCompat.checkSelfPermission(activity.applicationContext, permission) == PackageManager.PERMISSION_GRANTED
}
}
else -> {
false
}
}
}
}
}

View file

@ -117,4 +117,6 @@ sealed class RoomDetailAction : VectorViewModelAction {
// Live Location
object StopLiveLocationSharing : RoomDetailAction()
object OpenElementCallWidget : RoomDetailAction()
}

View file

@ -84,4 +84,5 @@ sealed class RoomDetailViewEvents : VectorViewEvents {
data class StartChatEffect(val type: ChatEffect) : RoomDetailViewEvents()
object StopChatEffects : RoomDetailViewEvents()
object RoomReplacementStarted : RoomDetailViewEvents()
object OpenElementCallWidget : RoomDetailViewEvents()
}

View file

@ -102,6 +102,8 @@ data class RoomDetailViewState(
// It can differs for a short period of time on the JitsiState as its computed async.
fun hasActiveJitsiWidget() = activeRoomWidgets()?.any { it.type == WidgetType.Jitsi && it.isActive }.orFalse()
fun hasActiveElementCallWidget() = activeRoomWidgets()?.any { it.type == WidgetType.ElementCall && it.isActive }.orFalse()
fun isDm() = asyncRoomSummary()?.isDirect == true
fun isThreadTimeline() = rootThreadEventId != null

View file

@ -47,6 +47,11 @@ class StartCallActionsHandler(
}
private fun handleCallRequest(isVideoCall: Boolean) = withState(timelineViewModel) { state ->
if (state.hasActiveElementCallWidget() && !isVideoCall) {
timelineViewModel.handle(RoomDetailAction.OpenElementCallWidget)
return@withState
}
val roomSummary = state.asyncRoomSummary.invoke() ?: return@withState
when (roomSummary.joinedMembersCount) {
1 -> {

View file

@ -498,6 +498,7 @@ class TimelineFragment @Inject constructor(
RoomDetailViewEvents.StopChatEffects -> handleStopChatEffects()
is RoomDetailViewEvents.DisplayAndAcceptCall -> acceptIncomingCall(it)
RoomDetailViewEvents.RoomReplacementStarted -> handleRoomReplacement()
RoomDetailViewEvents.OpenElementCallWidget -> handleOpenElementCallWidget()
}
}
@ -1090,9 +1091,8 @@ class TimelineFragment @Inject constructor(
2 -> state.isAllowedToStartWebRTCCall
else -> state.isAllowedToManageWidgets
}
setOf(R.id.voice_call, R.id.video_call).forEach {
menu.findItem(it).icon?.alpha = if (callButtonsEnabled) 0xFF else 0x40
}
menu.findItem(R.id.video_call).icon?.alpha = if (callButtonsEnabled) 0xFF else 0x40
menu.findItem(R.id.voice_call).icon?.alpha = if (callButtonsEnabled || state.hasActiveElementCallWidget()) 0xFF else 0x40
val matrixAppsMenuItem = menu.findItem(R.id.open_matrix_apps)
val widgetsCount = state.activeRoomWidgets.invoke()?.size ?: 0
@ -2653,6 +2653,15 @@ class TimelineFragment @Inject constructor(
.show(childFragmentManager, "ROOM_WIDGETS_BOTTOM_SHEET")
}
private fun handleOpenElementCallWidget() = withState(timelineViewModel) { state ->
state
.activeRoomWidgets()
?.find { it.type == WidgetType.ElementCall }
?.also { widget ->
navigator.openRoomWidget(requireContext(), state.roomId, widget)
}
}
override fun onTapToReturnToCall() {
callManager.getCurrentCall()?.let { call ->
VectorCallActivity.newIntent(

View file

@ -467,6 +467,13 @@ class TimelineViewModel @AssistedInject constructor(
}
is RoomDetailAction.EndPoll -> handleEndPoll(action.eventId)
RoomDetailAction.StopLiveLocationSharing -> handleStopLiveLocationSharing()
RoomDetailAction.OpenElementCallWidget -> handleOpenElementCallWidget()
}
}
private fun handleOpenElementCallWidget() = withState { state ->
if (state.hasActiveElementCallWidget()) {
_viewEvents.post(RoomDetailViewEvents.OpenElementCallWidget)
}
}
@ -752,7 +759,7 @@ class TimelineViewModel @AssistedInject constructor(
R.id.timeline_setting -> true
R.id.invite -> state.canInvite
R.id.open_matrix_apps -> true
R.id.voice_call -> state.isCallOptionAvailable()
R.id.voice_call -> state.isCallOptionAvailable() || state.hasActiveElementCallWidget()
R.id.video_call -> state.isCallOptionAvailable() || state.jitsiState.confId == null || state.jitsiState.hasJoined
// Show Join conference button only if there is an active conf id not joined. Otherwise fallback to default video disabled. ^
R.id.join_conference -> !state.isCallOptionAvailable() && state.jitsiState.confId != null && !state.jitsiState.hasJoined

View file

@ -465,6 +465,9 @@ class DefaultNavigator @Inject constructor(
val enableVideo = options?.get(JitsiCallViewModel.ENABLE_VIDEO_OPTION) == true
context.startActivity(VectorJitsiActivity.newIntent(context, roomId = roomId, widgetId = widget.widgetId, enableVideo = enableVideo))
}
} else if (widget.type is WidgetType.ElementCall) {
val widgetArgs = widgetArgsBuilder.buildElementCallWidgetArgs(roomId, widget)
context.startActivity(WidgetActivity.newIntent(context, widgetArgs))
} else {
val widgetArgs = widgetArgsBuilder.buildRoomWidgetArgs(roomId, widget)
context.startActivity(WidgetActivity.newIntent(context, widgetArgs))

View file

@ -26,4 +26,5 @@ sealed class WidgetAction : VectorViewModelAction {
object DeleteWidget : WidgetAction()
object RevokeWidget : WidgetAction()
object OnTermsReviewed : WidgetAction()
object CloseWidget : WidgetAction()
}

View file

@ -17,8 +17,19 @@
package im.vector.app.features.widgets
import android.app.Activity
import android.app.PendingIntent
import android.app.PictureInPictureParams
import android.app.RemoteAction
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.drawable.Icon
import android.os.Build
import android.util.Rational
import androidx.annotation.RequiresApi
import androidx.core.app.PictureInPictureModeChangedInfo
import androidx.core.util.Consumer
import androidx.core.view.isVisible
import com.airbnb.mvrx.Mavericks
import com.airbnb.mvrx.viewModel
@ -30,6 +41,7 @@ import im.vector.app.databinding.ActivityWidgetBinding
import im.vector.app.features.widgets.permissions.RoomWidgetPermissionBottomSheet
import im.vector.app.features.widgets.permissions.RoomWidgetPermissionViewEvents
import im.vector.app.features.widgets.permissions.RoomWidgetPermissionViewModel
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.events.model.Content
import java.io.Serializable
@ -40,6 +52,10 @@ class WidgetActivity : VectorBaseActivity<ActivityWidgetBinding>() {
private const val WIDGET_FRAGMENT_TAG = "WIDGET_FRAGMENT_TAG"
private const val WIDGET_PERMISSION_FRAGMENT_TAG = "WIDGET_PERMISSION_FRAGMENT_TAG"
private const val EXTRA_RESULT = "EXTRA_RESULT"
private const val REQUEST_CODE_HANGUP = 1
private const val ACTION_MEDIA_CONTROL = "MEDIA_CONTROL"
private const val EXTRA_CONTROL_TYPE = "EXTRA_CONTROL_TYPE"
private const val CONTROL_TYPE_HANGUP = 2
fun newIntent(context: Context, args: WidgetArgs): Intent {
return Intent(context, WidgetActivity::class.java).apply {
@ -82,29 +98,37 @@ class WidgetActivity : VectorBaseActivity<ActivityWidgetBinding>() {
}
}
permissionViewModel.observeViewEvents {
when (it) {
is RoomWidgetPermissionViewEvents.Close -> finish()
// Trust element call widget by default
if (widgetArgs.kind == WidgetKind.ELEMENT_CALL) {
if (supportFragmentManager.findFragmentByTag(WIDGET_FRAGMENT_TAG) == null) {
addOnPictureInPictureModeChangedListener(pictureInPictureModeChangedInfoConsumer)
addFragment(views.fragmentContainer, WidgetFragment::class.java, widgetArgs, WIDGET_FRAGMENT_TAG)
}
} else {
permissionViewModel.observeViewEvents {
when (it) {
is RoomWidgetPermissionViewEvents.Close -> finish()
}
}
}
viewModel.onEach(WidgetViewState::status) { ws ->
when (ws) {
WidgetStatus.UNKNOWN -> {
}
WidgetStatus.WIDGET_NOT_ALLOWED -> {
val dFrag = supportFragmentManager.findFragmentByTag(WIDGET_PERMISSION_FRAGMENT_TAG) as? RoomWidgetPermissionBottomSheet
if (dFrag != null && dFrag.dialog?.isShowing == true && !dFrag.isRemoving) {
return@onEach
} else {
RoomWidgetPermissionBottomSheet
.newInstance(widgetArgs)
.show(supportFragmentManager, WIDGET_PERMISSION_FRAGMENT_TAG)
viewModel.onEach(WidgetViewState::status) { ws ->
when (ws) {
WidgetStatus.UNKNOWN -> {
}
}
WidgetStatus.WIDGET_ALLOWED -> {
if (supportFragmentManager.findFragmentByTag(WIDGET_FRAGMENT_TAG) == null) {
addFragment(views.fragmentContainer, WidgetFragment::class.java, widgetArgs, WIDGET_FRAGMENT_TAG)
WidgetStatus.WIDGET_NOT_ALLOWED -> {
val dFrag = supportFragmentManager.findFragmentByTag(WIDGET_PERMISSION_FRAGMENT_TAG) as? RoomWidgetPermissionBottomSheet
if (dFrag != null && dFrag.dialog?.isShowing == true && !dFrag.isRemoving) {
return@onEach
} else {
RoomWidgetPermissionBottomSheet
.newInstance(widgetArgs)
.show(supportFragmentManager, WIDGET_PERMISSION_FRAGMENT_TAG)
}
}
WidgetStatus.WIDGET_ALLOWED -> {
if (supportFragmentManager.findFragmentByTag(WIDGET_FRAGMENT_TAG) == null) {
addFragment(views.fragmentContainer, WidgetFragment::class.java, widgetArgs, WIDGET_FRAGMENT_TAG)
}
}
}
}
@ -119,6 +143,64 @@ class WidgetActivity : VectorBaseActivity<ActivityWidgetBinding>() {
}
}
override fun onUserLeaveHint() {
super.onUserLeaveHint()
val widgetArgs: WidgetArgs? = intent?.extras?.getParcelable(Mavericks.KEY_ARG)
if (widgetArgs?.kind?.supportsPictureInPictureMode().orFalse()) {
enterPictureInPicture()
}
}
override fun onDestroy() {
removeOnPictureInPictureModeChangedListener(pictureInPictureModeChangedInfoConsumer)
super.onDestroy()
}
private fun enterPictureInPicture() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
createElementCallPipParams()?.let {
enterPictureInPictureMode(it)
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
private fun createElementCallPipParams(): PictureInPictureParams? {
val actions = mutableListOf<RemoteAction>()
val intent = Intent(ACTION_MEDIA_CONTROL).putExtra(EXTRA_CONTROL_TYPE, CONTROL_TYPE_HANGUP)
val pendingIntent = PendingIntent.getBroadcast(this, REQUEST_CODE_HANGUP, intent, 0)
val icon = Icon.createWithResource(this, R.drawable.ic_call_hangup)
actions.add(RemoteAction(icon, getString(R.string.call_notification_hangup), getString(R.string.call_notification_hangup), pendingIntent))
val aspectRatio = Rational(resources.getDimensionPixelSize(R.dimen.call_pip_width), resources.getDimensionPixelSize(R.dimen.call_pip_height))
return PictureInPictureParams.Builder()
.setAspectRatio(aspectRatio)
.setActions(actions)
.build()
}
private var hangupBroadcastReceiver: BroadcastReceiver? = null
private val pictureInPictureModeChangedInfoConsumer = Consumer<PictureInPictureModeChangedInfo> {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return@Consumer
if (isInPictureInPictureMode) {
hangupBroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == ACTION_MEDIA_CONTROL) {
val controlType = intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)
if (controlType == CONTROL_TYPE_HANGUP) {
viewModel.handle(WidgetAction.CloseWidget)
}
}
}
}
registerReceiver(hangupBroadcastReceiver, IntentFilter(ACTION_MEDIA_CONTROL))
} else {
unregisterReceiver(hangupBroadcastReceiver)
}
}
private fun handleClose(event: WidgetViewEvents.Close) {
if (event.content != null) {
val intent = createResultIntent(event.content)

View file

@ -78,6 +78,13 @@ class WidgetArgsBuilder @Inject constructor(
)
}
fun buildElementCallWidgetArgs(roomId: String, widget: Widget): WidgetArgs {
return buildRoomWidgetArgs(roomId, widget)
.copy(
kind = WidgetKind.ELEMENT_CALL
)
}
@Suppress("UNCHECKED_CAST")
private fun Map<String, String?>.filterNotNull(): Map<String, String> {
return filterValues { it != null } as Map<String, String>

View file

@ -43,6 +43,7 @@ import im.vector.app.core.extensions.registerStartForActivityResult
import im.vector.app.core.platform.OnBackPressed
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.platform.VectorMenuProvider
import im.vector.app.core.utils.CheckWebViewPermissionsUseCase
import im.vector.app.core.utils.openUrlInExternalBrowser
import im.vector.app.databinding.FragmentRoomWidgetBinding
import im.vector.app.features.webview.WebEventListener
@ -65,7 +66,8 @@ data class WidgetArgs(
) : Parcelable
class WidgetFragment @Inject constructor(
private val permissionUtils: WebviewPermissionUtils
private val permissionUtils: WebviewPermissionUtils,
private val checkWebViewPermissionsUseCase: CheckWebViewPermissionsUseCase,
) :
VectorBaseFragment<FragmentRoomWidgetBinding>(),
WebEventListener,
@ -81,7 +83,7 @@ class WidgetFragment @Inject constructor(
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
views.widgetWebView.setupForWidget(this)
views.widgetWebView.setupForWidget(requireActivity(), checkWebViewPermissionsUseCase, this)
if (fragmentArgs.kind.isAdmin()) {
viewModel.getPostAPIMediator().setWebView(views.widgetWebView)
}
@ -131,9 +133,11 @@ class WidgetFragment @Inject constructor(
override fun onPause() {
super.onPause()
views.widgetWebView.let {
it.pauseTimers()
it.onPause()
if (fragmentArgs.kind != WidgetKind.ELEMENT_CALL) {
views.widgetWebView.let {
it.pauseTimers()
it.onPause()
}
}
}
@ -298,7 +302,8 @@ class WidgetFragment @Inject constructor(
request = request,
context = requireContext(),
activity = requireActivity(),
activityResultLauncher = permissionResultLauncher
activityResultLauncher = permissionResultLauncher,
autoApprove = fragmentArgs.kind == WidgetKind.ELEMENT_CALL
)
}

View file

@ -147,9 +147,14 @@ class WidgetViewModel @AssistedInject constructor(
WidgetAction.DeleteWidget -> handleDeleteWidget()
WidgetAction.RevokeWidget -> handleRevokeWidget()
WidgetAction.OnTermsReviewed -> loadFormattedUrl(forceFetchToken = false)
WidgetAction.CloseWidget -> handleCloseWidget()
}
}
private fun handleCloseWidget() {
_viewEvents.post(WidgetViewEvents.Close())
}
private fun handleRevokeWidget() {
viewModelScope.launch {
val widgetId = initialState.widgetId ?: return@launch

View file

@ -33,11 +33,16 @@ enum class WidgetStatus {
enum class WidgetKind(@StringRes val nameRes: Int, val screenId: String?) {
ROOM(R.string.room_widget_activity_title, null),
STICKER_PICKER(R.string.title_activity_choose_sticker, WidgetType.StickerPicker.preferred),
INTEGRATION_MANAGER(0, null);
INTEGRATION_MANAGER(0, null),
ELEMENT_CALL(0, null);
fun isAdmin(): Boolean {
return this == STICKER_PICKER || this == INTEGRATION_MANAGER
}
fun supportsPictureInPictureMode(): Boolean {
return this == ELEMENT_CALL
}
}
data class WidgetViewState(

View file

@ -41,11 +41,22 @@ class WebviewPermissionUtils @Inject constructor(
request: PermissionRequest,
context: Context,
activity: FragmentActivity,
activityResultLauncher: ActivityResultLauncher<Array<String>>
activityResultLauncher: ActivityResultLauncher<Array<String>>,
autoApprove: Boolean = false
) {
if (autoApprove) {
onPermissionsSelected(
permissions = request.resources.toList(),
request = request,
activity = activity,
activityResultLauncher = activityResultLauncher)
return
}
val allowedPermissions = request.resources.map {
it to false
}.toMutableList()
MaterialAlertDialogBuilder(context)
.setTitle(title)
.setMultiChoiceItems(
@ -54,21 +65,10 @@ class WebviewPermissionUtils @Inject constructor(
allowedPermissions[which] = allowedPermissions[which].first to isChecked
}
.setPositiveButton(R.string.room_widget_resource_grant_permission) { _, _ ->
permissionRequest = request
selectedPermissions = allowedPermissions.mapNotNull { perm ->
val permissions = allowedPermissions.mapNotNull { perm ->
perm.first.takeIf { perm.second }
}
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()
}
onPermissionsSelected(permissions, request, activity, activityResultLauncher)
}
.setNegativeButton(R.string.room_widget_resource_decline_permission) { _, _ ->
request.deny()
@ -76,6 +76,27 @@ class WebviewPermissionUtils @Inject constructor(
.show()
}
private fun onPermissionsSelected(
permissions: List<String>,
request: PermissionRequest,
activity: FragmentActivity,
activityResultLauncher: ActivityResultLauncher<Array<String>>,
) {
permissionRequest = request
selectedPermissions = permissions
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()
}
}
fun onPermissionResult(result: Map<String, Boolean>) {
if (permissionRequest == null) {
fatalError(

View file

@ -17,18 +17,23 @@
package im.vector.app.features.widgets.webview
import android.annotation.SuppressLint
import android.app.Activity
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.PermissionRequest
import android.webkit.WebChromeClient
import android.webkit.WebView
import im.vector.app.R
import im.vector.app.core.utils.CheckWebViewPermissionsUseCase
import im.vector.app.features.themes.ThemeUtils
import im.vector.app.features.webview.VectorWebViewClient
import im.vector.app.features.webview.WebEventListener
@SuppressLint("NewApi")
fun WebView.setupForWidget(eventListener: WebEventListener) {
fun WebView.setupForWidget(activity: Activity,
checkWebViewPermissionsUseCase: CheckWebViewPermissionsUseCase,
eventListener: WebEventListener,
) {
// xml value seems ignored
setBackgroundColor(ThemeUtils.getColor(context, R.attr.colorSurface))
@ -56,10 +61,16 @@ fun WebView.setupForWidget(eventListener: WebEventListener) {
settings.displayZoomControls = false
settings.mediaPlaybackRequiresUserGesture = false
// Permission requests
webChromeClient = object : WebChromeClient() {
override fun onPermissionRequest(request: PermissionRequest) {
eventListener.onPermissionRequest(request)
if (checkWebViewPermissionsUseCase.execute(activity, request)) {
request.grant(request.resources)
} else {
eventListener.onPermissionRequest(request)
}
}
}
webViewClient = VectorWebViewClient(eventListener)

View file

@ -0,0 +1,126 @@
/*
* 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.core.utils
import android.app.Activity
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.webkit.PermissionRequest
import androidx.core.content.ContextCompat.checkSelfPermission
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import io.mockk.verify
import org.amshove.kluent.shouldBe
import org.junit.After
import org.junit.Before
import org.junit.Test
class CheckWebViewPermissionsUseCaseTest {
private val checkWebViewPermissionsUseCase = CheckWebViewPermissionsUseCase()
private val activity = mockk<Activity>().apply {
every { applicationContext } returns mockk()
}
@Before
fun setup() {
mockkStatic("androidx.core.content.ContextCompat")
}
@After
fun tearDown() {
unmockkStatic("androidx.core.content.ContextCompat")
}
@Test
fun `given an audio permission is granted when the web client requests audio permission then use case returns true`() {
val permissionRequest = givenAPermissionRequest(arrayOf(PermissionRequest.RESOURCE_AUDIO_CAPTURE))
every { checkSelfPermission(activity.applicationContext, any()) } returns PackageManager.PERMISSION_GRANTED
checkWebViewPermissionsUseCase.execute(activity, permissionRequest) shouldBe true
verifyPermissionsChecked(activity.applicationContext, PERMISSIONS_FOR_AUDIO_IP_CALL)
}
@Test
fun `given a camera permission is granted when the web client requests video permission then use case returns true`() {
val permissionRequest = givenAPermissionRequest(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE))
every { checkSelfPermission(activity.applicationContext, any()) } returns PackageManager.PERMISSION_GRANTED
checkWebViewPermissionsUseCase.execute(activity, permissionRequest) shouldBe true
verifyPermissionsChecked(activity.applicationContext, PERMISSIONS_FOR_VIDEO_IP_CALL)
}
@Test
fun `given an audio and camera permissions are granted when the web client requests audio and video permissions then use case returns true`() {
val permissionRequest = givenAPermissionRequest(arrayOf(PermissionRequest.RESOURCE_AUDIO_CAPTURE, PermissionRequest.RESOURCE_VIDEO_CAPTURE))
every { checkSelfPermission(activity.applicationContext, any()) } returns PackageManager.PERMISSION_GRANTED
checkWebViewPermissionsUseCase.execute(activity, permissionRequest) shouldBe true
verifyPermissionsChecked(activity.applicationContext, PERMISSIONS_FOR_AUDIO_IP_CALL + PERMISSIONS_FOR_VIDEO_IP_CALL)
}
@Test
fun `given an audio permission is granted but camera isn't when the web client requests audio and video permissions then use case returns false`() {
val permissionRequest = givenAPermissionRequest(arrayOf(PermissionRequest.RESOURCE_AUDIO_CAPTURE, PermissionRequest.RESOURCE_VIDEO_CAPTURE))
PERMISSIONS_FOR_AUDIO_IP_CALL.forEach {
every { checkSelfPermission(activity.applicationContext, it) } returns PackageManager.PERMISSION_GRANTED
}
PERMISSIONS_FOR_VIDEO_IP_CALL.forEach {
every { checkSelfPermission(activity.applicationContext, it) } returns PackageManager.PERMISSION_DENIED
}
checkWebViewPermissionsUseCase.execute(activity, permissionRequest) shouldBe false
verifyPermissionsChecked(activity.applicationContext, PERMISSIONS_FOR_AUDIO_IP_CALL + PERMISSIONS_FOR_VIDEO_IP_CALL.first())
}
@Test
fun `given an audio and camera permissions are granted when the web client requests another permission then use case returns false`() {
val permissionRequest = givenAPermissionRequest(arrayOf(PermissionRequest.RESOURCE_AUDIO_CAPTURE, PermissionRequest.RESOURCE_MIDI_SYSEX))
every { checkSelfPermission(activity.applicationContext, any()) } returns PackageManager.PERMISSION_GRANTED
checkWebViewPermissionsUseCase.execute(activity, permissionRequest) shouldBe false
verifyPermissionsChecked(activity.applicationContext, PERMISSIONS_FOR_AUDIO_IP_CALL)
}
private fun verifyPermissionsChecked(context: Context, permissions: List<String>) {
permissions.forEach {
verify { checkSelfPermission(context, it) }
}
}
private fun givenAPermissionRequest(resources: Array<String>): PermissionRequest {
return object : PermissionRequest() {
override fun getOrigin(): Uri {
return mockk()
}
override fun getResources(): Array<String> {
return resources
}
override fun grant(resources: Array<out String>?) {
}
override fun deny() {
}
}
}
}