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() {
+ }
+ }
+ }
+}