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.StickerPicker,
WidgetType.Grafana, WidgetType.Grafana,
WidgetType.Custom, 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 Grafana : WidgetType("m.grafana")
object Custom : WidgetType("m.custom") object Custom : WidgetType("m.custom")
object IntegrationManager : WidgetType("m.integration_manager") object IntegrationManager : WidgetType("m.integration_manager")
object ElementCall : WidgetType("io.element.call")
data class Fallback(override val preferred: String) : WidgetType(preferred) data class Fallback(override val preferred: String) : WidgetType(preferred)
fun matches(type: String): Boolean { fun matches(type: String): Boolean {

View file

@ -308,7 +308,8 @@
<activity android:name=".features.terms.ReviewTermsActivity" /> <activity android:name=".features.terms.ReviewTermsActivity" />
<activity <activity
android:name=".features.widgets.WidgetActivity" 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.pin.PinActivity" />
<activity android:name=".features.analytics.ui.consent.AnalyticsOptInActivity" /> <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 // Live Location
object StopLiveLocationSharing : RoomDetailAction() object StopLiveLocationSharing : RoomDetailAction()
object OpenElementCallWidget : RoomDetailAction()
} }

View file

@ -84,4 +84,5 @@ sealed class RoomDetailViewEvents : VectorViewEvents {
data class StartChatEffect(val type: ChatEffect) : RoomDetailViewEvents() data class StartChatEffect(val type: ChatEffect) : RoomDetailViewEvents()
object StopChatEffects : RoomDetailViewEvents() object StopChatEffects : RoomDetailViewEvents()
object RoomReplacementStarted : 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. // 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 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 isDm() = asyncRoomSummary()?.isDirect == true
fun isThreadTimeline() = rootThreadEventId != null fun isThreadTimeline() = rootThreadEventId != null

View file

@ -47,6 +47,11 @@ class StartCallActionsHandler(
} }
private fun handleCallRequest(isVideoCall: Boolean) = withState(timelineViewModel) { state -> 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 val roomSummary = state.asyncRoomSummary.invoke() ?: return@withState
when (roomSummary.joinedMembersCount) { when (roomSummary.joinedMembersCount) {
1 -> { 1 -> {

View file

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

View file

@ -467,6 +467,13 @@ class TimelineViewModel @AssistedInject constructor(
} }
is RoomDetailAction.EndPoll -> handleEndPoll(action.eventId) is RoomDetailAction.EndPoll -> handleEndPoll(action.eventId)
RoomDetailAction.StopLiveLocationSharing -> handleStopLiveLocationSharing() 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.timeline_setting -> true
R.id.invite -> state.canInvite R.id.invite -> state.canInvite
R.id.open_matrix_apps -> true 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 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. ^ // 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 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 val enableVideo = options?.get(JitsiCallViewModel.ENABLE_VIDEO_OPTION) == true
context.startActivity(VectorJitsiActivity.newIntent(context, roomId = roomId, widgetId = widget.widgetId, enableVideo = enableVideo)) 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 { } else {
val widgetArgs = widgetArgsBuilder.buildRoomWidgetArgs(roomId, widget) val widgetArgs = widgetArgsBuilder.buildRoomWidgetArgs(roomId, widget)
context.startActivity(WidgetActivity.newIntent(context, widgetArgs)) context.startActivity(WidgetActivity.newIntent(context, widgetArgs))

View file

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

View file

@ -17,8 +17,19 @@
package im.vector.app.features.widgets package im.vector.app.features.widgets
import android.app.Activity 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.Context
import android.content.Intent 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 androidx.core.view.isVisible
import com.airbnb.mvrx.Mavericks import com.airbnb.mvrx.Mavericks
import com.airbnb.mvrx.viewModel 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.RoomWidgetPermissionBottomSheet
import im.vector.app.features.widgets.permissions.RoomWidgetPermissionViewEvents import im.vector.app.features.widgets.permissions.RoomWidgetPermissionViewEvents
import im.vector.app.features.widgets.permissions.RoomWidgetPermissionViewModel 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 org.matrix.android.sdk.api.session.events.model.Content
import java.io.Serializable 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_FRAGMENT_TAG = "WIDGET_FRAGMENT_TAG"
private const val WIDGET_PERMISSION_FRAGMENT_TAG = "WIDGET_PERMISSION_FRAGMENT_TAG" private const val WIDGET_PERMISSION_FRAGMENT_TAG = "WIDGET_PERMISSION_FRAGMENT_TAG"
private const val EXTRA_RESULT = "EXTRA_RESULT" 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 { fun newIntent(context: Context, args: WidgetArgs): Intent {
return Intent(context, WidgetActivity::class.java).apply { return Intent(context, WidgetActivity::class.java).apply {
@ -82,29 +98,37 @@ class WidgetActivity : VectorBaseActivity<ActivityWidgetBinding>() {
} }
} }
permissionViewModel.observeViewEvents { // Trust element call widget by default
when (it) { if (widgetArgs.kind == WidgetKind.ELEMENT_CALL) {
is RoomWidgetPermissionViewEvents.Close -> finish() 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 -> viewModel.onEach(WidgetViewState::status) { ws ->
when (ws) { when (ws) {
WidgetStatus.UNKNOWN -> { 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)
} }
} WidgetStatus.WIDGET_NOT_ALLOWED -> {
WidgetStatus.WIDGET_ALLOWED -> { val dFrag = supportFragmentManager.findFragmentByTag(WIDGET_PERMISSION_FRAGMENT_TAG) as? RoomWidgetPermissionBottomSheet
if (supportFragmentManager.findFragmentByTag(WIDGET_FRAGMENT_TAG) == null) { if (dFrag != null && dFrag.dialog?.isShowing == true && !dFrag.isRemoving) {
addFragment(views.fragmentContainer, WidgetFragment::class.java, widgetArgs, WIDGET_FRAGMENT_TAG) 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) { private fun handleClose(event: WidgetViewEvents.Close) {
if (event.content != null) { if (event.content != null) {
val intent = createResultIntent(event.content) 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") @Suppress("UNCHECKED_CAST")
private fun Map<String, String?>.filterNotNull(): Map<String, String> { private fun Map<String, String?>.filterNotNull(): Map<String, String> {
return filterValues { it != null } as 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.OnBackPressed
import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.platform.VectorMenuProvider 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.core.utils.openUrlInExternalBrowser
import im.vector.app.databinding.FragmentRoomWidgetBinding import im.vector.app.databinding.FragmentRoomWidgetBinding
import im.vector.app.features.webview.WebEventListener import im.vector.app.features.webview.WebEventListener
@ -65,7 +66,8 @@ data class WidgetArgs(
) : Parcelable ) : Parcelable
class WidgetFragment @Inject constructor( class WidgetFragment @Inject constructor(
private val permissionUtils: WebviewPermissionUtils private val permissionUtils: WebviewPermissionUtils,
private val checkWebViewPermissionsUseCase: CheckWebViewPermissionsUseCase,
) : ) :
VectorBaseFragment<FragmentRoomWidgetBinding>(), VectorBaseFragment<FragmentRoomWidgetBinding>(),
WebEventListener, WebEventListener,
@ -81,7 +83,7 @@ class WidgetFragment @Inject constructor(
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
views.widgetWebView.setupForWidget(this) views.widgetWebView.setupForWidget(requireActivity(), checkWebViewPermissionsUseCase, this)
if (fragmentArgs.kind.isAdmin()) { if (fragmentArgs.kind.isAdmin()) {
viewModel.getPostAPIMediator().setWebView(views.widgetWebView) viewModel.getPostAPIMediator().setWebView(views.widgetWebView)
} }
@ -131,9 +133,11 @@ class WidgetFragment @Inject constructor(
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
views.widgetWebView.let { if (fragmentArgs.kind != WidgetKind.ELEMENT_CALL) {
it.pauseTimers() views.widgetWebView.let {
it.onPause() it.pauseTimers()
it.onPause()
}
} }
} }
@ -298,7 +302,8 @@ class WidgetFragment @Inject constructor(
request = request, request = request,
context = requireContext(), context = requireContext(),
activity = requireActivity(), 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.DeleteWidget -> handleDeleteWidget()
WidgetAction.RevokeWidget -> handleRevokeWidget() WidgetAction.RevokeWidget -> handleRevokeWidget()
WidgetAction.OnTermsReviewed -> loadFormattedUrl(forceFetchToken = false) WidgetAction.OnTermsReviewed -> loadFormattedUrl(forceFetchToken = false)
WidgetAction.CloseWidget -> handleCloseWidget()
} }
} }
private fun handleCloseWidget() {
_viewEvents.post(WidgetViewEvents.Close())
}
private fun handleRevokeWidget() { private fun handleRevokeWidget() {
viewModelScope.launch { viewModelScope.launch {
val widgetId = initialState.widgetId ?: return@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?) { enum class WidgetKind(@StringRes val nameRes: Int, val screenId: String?) {
ROOM(R.string.room_widget_activity_title, null), ROOM(R.string.room_widget_activity_title, null),
STICKER_PICKER(R.string.title_activity_choose_sticker, WidgetType.StickerPicker.preferred), 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 { fun isAdmin(): Boolean {
return this == STICKER_PICKER || this == INTEGRATION_MANAGER return this == STICKER_PICKER || this == INTEGRATION_MANAGER
} }
fun supportsPictureInPictureMode(): Boolean {
return this == ELEMENT_CALL
}
} }
data class WidgetViewState( data class WidgetViewState(

View file

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

View file

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