diff --git a/changelog.d/6616.feature b/changelog.d/6616.feature new file mode 100644 index 0000000000..d013771764 --- /dev/null +++ b/changelog.d/6616.feature @@ -0,0 +1 @@ +Support element call widget diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/widgets/model/WidgetType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/widgets/model/WidgetType.kt index ee098f9bf2..f02fe4f9de 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/widgets/model/WidgetType.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/widgets/model/WidgetType.kt @@ -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 { diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml index b7bdac6879..fa89013707 100644 --- a/vector/src/main/AndroidManifest.xml +++ b/vector/src/main/AndroidManifest.xml @@ -308,7 +308,8 @@ + android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation" + android:supportsPictureInPicture="true" /> diff --git a/vector/src/main/java/im/vector/app/core/utils/CheckWebViewPermissionsUseCase.kt b/vector/src/main/java/im/vector/app/core/utils/CheckWebViewPermissionsUseCase.kt new file mode 100644 index 0000000000..df84e24f90 --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/utils/CheckWebViewPermissionsUseCase.kt @@ -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 + } + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt index 64670c73ac..c1e3b58a80 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt @@ -117,4 +117,6 @@ sealed class RoomDetailAction : VectorViewModelAction { // Live Location object StopLiveLocationSharing : RoomDetailAction() + + object OpenElementCallWidget : RoomDetailAction() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt index dcfee2d919..3af849e965 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt @@ -84,4 +84,5 @@ sealed class RoomDetailViewEvents : VectorViewEvents { data class StartChatEffect(val type: ChatEffect) : RoomDetailViewEvents() object StopChatEffects : RoomDetailViewEvents() object RoomReplacementStarted : RoomDetailViewEvents() + object OpenElementCallWidget : RoomDetailViewEvents() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt index 8500d1ed96..7aa7d5a877 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt @@ -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 diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/StartCallActionsHandler.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/StartCallActionsHandler.kt index ba691de5d2..8d2d086275 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/StartCallActionsHandler.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/StartCallActionsHandler.kt @@ -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 -> { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt index 562f2d4aea..f336ffc67c 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt @@ -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( diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt index e3ea8a0826..c73ed3b672 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt @@ -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 diff --git a/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt b/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt index 7680b40506..2a1095fb7a 100644 --- a/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt +++ b/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt @@ -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)) diff --git a/vector/src/main/java/im/vector/app/features/widgets/WidgetAction.kt b/vector/src/main/java/im/vector/app/features/widgets/WidgetAction.kt index b72ea68b7f..d5d8e95aa6 100644 --- a/vector/src/main/java/im/vector/app/features/widgets/WidgetAction.kt +++ b/vector/src/main/java/im/vector/app/features/widgets/WidgetAction.kt @@ -26,4 +26,5 @@ sealed class WidgetAction : VectorViewModelAction { object DeleteWidget : WidgetAction() object RevokeWidget : WidgetAction() object OnTermsReviewed : WidgetAction() + object CloseWidget : WidgetAction() } diff --git a/vector/src/main/java/im/vector/app/features/widgets/WidgetActivity.kt b/vector/src/main/java/im/vector/app/features/widgets/WidgetActivity.kt index 954f622801..0b78d8d2f1 100644 --- a/vector/src/main/java/im/vector/app/features/widgets/WidgetActivity.kt +++ b/vector/src/main/java/im/vector/app/features/widgets/WidgetActivity.kt @@ -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() { 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() { } } - 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() { } } + 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() + 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 { + 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) diff --git a/vector/src/main/java/im/vector/app/features/widgets/WidgetArgsBuilder.kt b/vector/src/main/java/im/vector/app/features/widgets/WidgetArgsBuilder.kt index 777bd9cc7e..83ea100cb6 100644 --- a/vector/src/main/java/im/vector/app/features/widgets/WidgetArgsBuilder.kt +++ b/vector/src/main/java/im/vector/app/features/widgets/WidgetArgsBuilder.kt @@ -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.filterNotNull(): Map { return filterValues { it != null } as Map 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 5501031e92..9ac085fa89 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 @@ -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(), 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 ) } diff --git a/vector/src/main/java/im/vector/app/features/widgets/WidgetViewModel.kt b/vector/src/main/java/im/vector/app/features/widgets/WidgetViewModel.kt index b3f4712815..ecd6ca2fd6 100644 --- a/vector/src/main/java/im/vector/app/features/widgets/WidgetViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/widgets/WidgetViewModel.kt @@ -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 diff --git a/vector/src/main/java/im/vector/app/features/widgets/WidgetViewState.kt b/vector/src/main/java/im/vector/app/features/widgets/WidgetViewState.kt index 2d98f734dd..cd2ed23980 100644 --- a/vector/src/main/java/im/vector/app/features/widgets/WidgetViewState.kt +++ b/vector/src/main/java/im/vector/app/features/widgets/WidgetViewState.kt @@ -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( 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 fa7b842ab9..44af4ec335 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 @@ -41,11 +41,22 @@ class WebviewPermissionUtils @Inject constructor( request: PermissionRequest, context: Context, activity: FragmentActivity, - activityResultLauncher: ActivityResultLauncher> + activityResultLauncher: ActivityResultLauncher>, + 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, + request: PermissionRequest, + activity: FragmentActivity, + activityResultLauncher: ActivityResultLauncher>, + ) { + 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) { if (permissionRequest == null) { fatalError( 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 0207987ca3..ac9930866f 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 @@ -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) diff --git a/vector/src/test/java/im/vector/app/core/utils/CheckWebViewPermissionsUseCaseTest.kt b/vector/src/test/java/im/vector/app/core/utils/CheckWebViewPermissionsUseCaseTest.kt new file mode 100644 index 0000000000..fe082ab5b6 --- /dev/null +++ b/vector/src/test/java/im/vector/app/core/utils/CheckWebViewPermissionsUseCaseTest.kt @@ -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().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) { + permissions.forEach { + verify { checkSelfPermission(context, it) } + } + } + + private fun givenAPermissionRequest(resources: Array): PermissionRequest { + return object : PermissionRequest() { + override fun getOrigin(): Uri { + return mockk() + } + + override fun getResources(): Array { + return resources + } + + override fun grant(resources: Array?) { + } + + override fun deny() { + } + } + } +}