mirror of
https://github.com/element-hq/element-android
synced 2024-11-27 11:59:12 +03:00
Merge pull request #6616 from vector-im/feature/ons/element_call_widget
Support element call widget (PSG-627)
This commit is contained in:
commit
75de805417
20 changed files with 397 additions and 49 deletions
1
changelog.d/6616.feature
Normal file
1
changelog.d/6616.feature
Normal file
|
@ -0,0 +1 @@
|
|||
Support element call widget
|
|
@ -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 {
|
||||
|
|
|
@ -308,7 +308,8 @@
|
|||
<activity android:name=".features.terms.ReviewTermsActivity" />
|
||||
<activity
|
||||
android:name=".features.widgets.WidgetActivity"
|
||||
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation" />
|
||||
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
|
||||
android:supportsPictureInPicture="true" />
|
||||
|
||||
<activity android:name=".features.pin.PinActivity" />
|
||||
<activity android:name=".features.analytics.ui.consent.AnalyticsOptInActivity" />
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -117,4 +117,6 @@ sealed class RoomDetailAction : VectorViewModelAction {
|
|||
|
||||
// Live Location
|
||||
object StopLiveLocationSharing : RoomDetailAction()
|
||||
|
||||
object OpenElementCallWidget : RoomDetailAction()
|
||||
}
|
||||
|
|
|
@ -84,4 +84,5 @@ sealed class RoomDetailViewEvents : VectorViewEvents {
|
|||
data class StartChatEffect(val type: ChatEffect) : RoomDetailViewEvents()
|
||||
object StopChatEffects : RoomDetailViewEvents()
|
||||
object RoomReplacementStarted : RoomDetailViewEvents()
|
||||
object OpenElementCallWidget : RoomDetailViewEvents()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 -> {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -26,4 +26,5 @@ sealed class WidgetAction : VectorViewModelAction {
|
|||
object DeleteWidget : WidgetAction()
|
||||
object RevokeWidget : WidgetAction()
|
||||
object OnTermsReviewed : WidgetAction()
|
||||
object CloseWidget : WidgetAction()
|
||||
}
|
||||
|
|
|
@ -17,8 +17,19 @@
|
|||
package im.vector.app.features.widgets
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.PendingIntent
|
||||
import android.app.PictureInPictureParams
|
||||
import android.app.RemoteAction
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.drawable.Icon
|
||||
import android.os.Build
|
||||
import android.util.Rational
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.PictureInPictureModeChangedInfo
|
||||
import androidx.core.util.Consumer
|
||||
import androidx.core.view.isVisible
|
||||
import com.airbnb.mvrx.Mavericks
|
||||
import com.airbnb.mvrx.viewModel
|
||||
|
@ -30,6 +41,7 @@ import im.vector.app.databinding.ActivityWidgetBinding
|
|||
import im.vector.app.features.widgets.permissions.RoomWidgetPermissionBottomSheet
|
||||
import im.vector.app.features.widgets.permissions.RoomWidgetPermissionViewEvents
|
||||
import im.vector.app.features.widgets.permissions.RoomWidgetPermissionViewModel
|
||||
import org.matrix.android.sdk.api.extensions.orFalse
|
||||
import org.matrix.android.sdk.api.session.events.model.Content
|
||||
import java.io.Serializable
|
||||
|
||||
|
@ -40,6 +52,10 @@ class WidgetActivity : VectorBaseActivity<ActivityWidgetBinding>() {
|
|||
private const val WIDGET_FRAGMENT_TAG = "WIDGET_FRAGMENT_TAG"
|
||||
private const val WIDGET_PERMISSION_FRAGMENT_TAG = "WIDGET_PERMISSION_FRAGMENT_TAG"
|
||||
private const val EXTRA_RESULT = "EXTRA_RESULT"
|
||||
private const val REQUEST_CODE_HANGUP = 1
|
||||
private const val ACTION_MEDIA_CONTROL = "MEDIA_CONTROL"
|
||||
private const val EXTRA_CONTROL_TYPE = "EXTRA_CONTROL_TYPE"
|
||||
private const val CONTROL_TYPE_HANGUP = 2
|
||||
|
||||
fun newIntent(context: Context, args: WidgetArgs): Intent {
|
||||
return Intent(context, WidgetActivity::class.java).apply {
|
||||
|
@ -82,29 +98,37 @@ class WidgetActivity : VectorBaseActivity<ActivityWidgetBinding>() {
|
|||
}
|
||||
}
|
||||
|
||||
permissionViewModel.observeViewEvents {
|
||||
when (it) {
|
||||
is RoomWidgetPermissionViewEvents.Close -> finish()
|
||||
// Trust element call widget by default
|
||||
if (widgetArgs.kind == WidgetKind.ELEMENT_CALL) {
|
||||
if (supportFragmentManager.findFragmentByTag(WIDGET_FRAGMENT_TAG) == null) {
|
||||
addOnPictureInPictureModeChangedListener(pictureInPictureModeChangedInfoConsumer)
|
||||
addFragment(views.fragmentContainer, WidgetFragment::class.java, widgetArgs, WIDGET_FRAGMENT_TAG)
|
||||
}
|
||||
} else {
|
||||
permissionViewModel.observeViewEvents {
|
||||
when (it) {
|
||||
is RoomWidgetPermissionViewEvents.Close -> finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.onEach(WidgetViewState::status) { ws ->
|
||||
when (ws) {
|
||||
WidgetStatus.UNKNOWN -> {
|
||||
}
|
||||
WidgetStatus.WIDGET_NOT_ALLOWED -> {
|
||||
val dFrag = supportFragmentManager.findFragmentByTag(WIDGET_PERMISSION_FRAGMENT_TAG) as? RoomWidgetPermissionBottomSheet
|
||||
if (dFrag != null && dFrag.dialog?.isShowing == true && !dFrag.isRemoving) {
|
||||
return@onEach
|
||||
} else {
|
||||
RoomWidgetPermissionBottomSheet
|
||||
.newInstance(widgetArgs)
|
||||
.show(supportFragmentManager, WIDGET_PERMISSION_FRAGMENT_TAG)
|
||||
viewModel.onEach(WidgetViewState::status) { ws ->
|
||||
when (ws) {
|
||||
WidgetStatus.UNKNOWN -> {
|
||||
}
|
||||
}
|
||||
WidgetStatus.WIDGET_ALLOWED -> {
|
||||
if (supportFragmentManager.findFragmentByTag(WIDGET_FRAGMENT_TAG) == null) {
|
||||
addFragment(views.fragmentContainer, WidgetFragment::class.java, widgetArgs, WIDGET_FRAGMENT_TAG)
|
||||
WidgetStatus.WIDGET_NOT_ALLOWED -> {
|
||||
val dFrag = supportFragmentManager.findFragmentByTag(WIDGET_PERMISSION_FRAGMENT_TAG) as? RoomWidgetPermissionBottomSheet
|
||||
if (dFrag != null && dFrag.dialog?.isShowing == true && !dFrag.isRemoving) {
|
||||
return@onEach
|
||||
} else {
|
||||
RoomWidgetPermissionBottomSheet
|
||||
.newInstance(widgetArgs)
|
||||
.show(supportFragmentManager, WIDGET_PERMISSION_FRAGMENT_TAG)
|
||||
}
|
||||
}
|
||||
WidgetStatus.WIDGET_ALLOWED -> {
|
||||
if (supportFragmentManager.findFragmentByTag(WIDGET_FRAGMENT_TAG) == null) {
|
||||
addFragment(views.fragmentContainer, WidgetFragment::class.java, widgetArgs, WIDGET_FRAGMENT_TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -119,6 +143,64 @@ class WidgetActivity : VectorBaseActivity<ActivityWidgetBinding>() {
|
|||
}
|
||||
}
|
||||
|
||||
override fun onUserLeaveHint() {
|
||||
super.onUserLeaveHint()
|
||||
val widgetArgs: WidgetArgs? = intent?.extras?.getParcelable(Mavericks.KEY_ARG)
|
||||
if (widgetArgs?.kind?.supportsPictureInPictureMode().orFalse()) {
|
||||
enterPictureInPicture()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
removeOnPictureInPictureModeChangedListener(pictureInPictureModeChangedInfoConsumer)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun enterPictureInPicture() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
createElementCallPipParams()?.let {
|
||||
enterPictureInPictureMode(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun createElementCallPipParams(): PictureInPictureParams? {
|
||||
val actions = mutableListOf<RemoteAction>()
|
||||
val intent = Intent(ACTION_MEDIA_CONTROL).putExtra(EXTRA_CONTROL_TYPE, CONTROL_TYPE_HANGUP)
|
||||
val pendingIntent = PendingIntent.getBroadcast(this, REQUEST_CODE_HANGUP, intent, 0)
|
||||
val icon = Icon.createWithResource(this, R.drawable.ic_call_hangup)
|
||||
actions.add(RemoteAction(icon, getString(R.string.call_notification_hangup), getString(R.string.call_notification_hangup), pendingIntent))
|
||||
|
||||
val aspectRatio = Rational(resources.getDimensionPixelSize(R.dimen.call_pip_width), resources.getDimensionPixelSize(R.dimen.call_pip_height))
|
||||
return PictureInPictureParams.Builder()
|
||||
.setAspectRatio(aspectRatio)
|
||||
.setActions(actions)
|
||||
.build()
|
||||
}
|
||||
|
||||
private var hangupBroadcastReceiver: BroadcastReceiver? = null
|
||||
|
||||
private val pictureInPictureModeChangedInfoConsumer = Consumer<PictureInPictureModeChangedInfo> {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return@Consumer
|
||||
|
||||
if (isInPictureInPictureMode) {
|
||||
hangupBroadcastReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == ACTION_MEDIA_CONTROL) {
|
||||
val controlType = intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)
|
||||
if (controlType == CONTROL_TYPE_HANGUP) {
|
||||
viewModel.handle(WidgetAction.CloseWidget)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
registerReceiver(hangupBroadcastReceiver, IntentFilter(ACTION_MEDIA_CONTROL))
|
||||
} else {
|
||||
unregisterReceiver(hangupBroadcastReceiver)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleClose(event: WidgetViewEvents.Close) {
|
||||
if (event.content != null) {
|
||||
val intent = createResultIntent(event.content)
|
||||
|
|
|
@ -78,6 +78,13 @@ class WidgetArgsBuilder @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
fun buildElementCallWidgetArgs(roomId: String, widget: Widget): WidgetArgs {
|
||||
return buildRoomWidgetArgs(roomId, widget)
|
||||
.copy(
|
||||
kind = WidgetKind.ELEMENT_CALL
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun Map<String, String?>.filterNotNull(): Map<String, String> {
|
||||
return filterValues { it != null } as Map<String, String>
|
||||
|
|
|
@ -43,6 +43,7 @@ import im.vector.app.core.extensions.registerStartForActivityResult
|
|||
import im.vector.app.core.platform.OnBackPressed
|
||||
import im.vector.app.core.platform.VectorBaseFragment
|
||||
import im.vector.app.core.platform.VectorMenuProvider
|
||||
import im.vector.app.core.utils.CheckWebViewPermissionsUseCase
|
||||
import im.vector.app.core.utils.openUrlInExternalBrowser
|
||||
import im.vector.app.databinding.FragmentRoomWidgetBinding
|
||||
import im.vector.app.features.webview.WebEventListener
|
||||
|
@ -65,7 +66,8 @@ data class WidgetArgs(
|
|||
) : Parcelable
|
||||
|
||||
class WidgetFragment @Inject constructor(
|
||||
private val permissionUtils: WebviewPermissionUtils
|
||||
private val permissionUtils: WebviewPermissionUtils,
|
||||
private val checkWebViewPermissionsUseCase: CheckWebViewPermissionsUseCase,
|
||||
) :
|
||||
VectorBaseFragment<FragmentRoomWidgetBinding>(),
|
||||
WebEventListener,
|
||||
|
@ -81,7 +83,7 @@ class WidgetFragment @Inject constructor(
|
|||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
views.widgetWebView.setupForWidget(this)
|
||||
views.widgetWebView.setupForWidget(requireActivity(), checkWebViewPermissionsUseCase, this)
|
||||
if (fragmentArgs.kind.isAdmin()) {
|
||||
viewModel.getPostAPIMediator().setWebView(views.widgetWebView)
|
||||
}
|
||||
|
@ -131,9 +133,11 @@ class WidgetFragment @Inject constructor(
|
|||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
views.widgetWebView.let {
|
||||
it.pauseTimers()
|
||||
it.onPause()
|
||||
if (fragmentArgs.kind != WidgetKind.ELEMENT_CALL) {
|
||||
views.widgetWebView.let {
|
||||
it.pauseTimers()
|
||||
it.onPause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -298,7 +302,8 @@ class WidgetFragment @Inject constructor(
|
|||
request = request,
|
||||
context = requireContext(),
|
||||
activity = requireActivity(),
|
||||
activityResultLauncher = permissionResultLauncher
|
||||
activityResultLauncher = permissionResultLauncher,
|
||||
autoApprove = fragmentArgs.kind == WidgetKind.ELEMENT_CALL
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -41,11 +41,22 @@ class WebviewPermissionUtils @Inject constructor(
|
|||
request: PermissionRequest,
|
||||
context: Context,
|
||||
activity: FragmentActivity,
|
||||
activityResultLauncher: ActivityResultLauncher<Array<String>>
|
||||
activityResultLauncher: ActivityResultLauncher<Array<String>>,
|
||||
autoApprove: Boolean = false
|
||||
) {
|
||||
if (autoApprove) {
|
||||
onPermissionsSelected(
|
||||
permissions = request.resources.toList(),
|
||||
request = request,
|
||||
activity = activity,
|
||||
activityResultLauncher = activityResultLauncher)
|
||||
return
|
||||
}
|
||||
|
||||
val allowedPermissions = request.resources.map {
|
||||
it to false
|
||||
}.toMutableList()
|
||||
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setTitle(title)
|
||||
.setMultiChoiceItems(
|
||||
|
@ -54,21 +65,10 @@ class WebviewPermissionUtils @Inject constructor(
|
|||
allowedPermissions[which] = allowedPermissions[which].first to isChecked
|
||||
}
|
||||
.setPositiveButton(R.string.room_widget_resource_grant_permission) { _, _ ->
|
||||
permissionRequest = request
|
||||
selectedPermissions = allowedPermissions.mapNotNull { perm ->
|
||||
val permissions = allowedPermissions.mapNotNull { perm ->
|
||||
perm.first.takeIf { perm.second }
|
||||
}
|
||||
|
||||
val requiredAndroidPermissions = selectedPermissions.mapNotNull { permission ->
|
||||
webPermissionToAndroidPermission(permission)
|
||||
}
|
||||
|
||||
// When checkPermissions returns false, some of the required Android permissions will
|
||||
// have to be requested and the flow completes asynchronously via onPermissionResult
|
||||
if (checkPermissions(requiredAndroidPermissions, activity, activityResultLauncher)) {
|
||||
request.grant(selectedPermissions.toTypedArray())
|
||||
reset()
|
||||
}
|
||||
onPermissionsSelected(permissions, request, activity, activityResultLauncher)
|
||||
}
|
||||
.setNegativeButton(R.string.room_widget_resource_decline_permission) { _, _ ->
|
||||
request.deny()
|
||||
|
@ -76,6 +76,27 @@ class WebviewPermissionUtils @Inject constructor(
|
|||
.show()
|
||||
}
|
||||
|
||||
private fun onPermissionsSelected(
|
||||
permissions: List<String>,
|
||||
request: PermissionRequest,
|
||||
activity: FragmentActivity,
|
||||
activityResultLauncher: ActivityResultLauncher<Array<String>>,
|
||||
) {
|
||||
permissionRequest = request
|
||||
selectedPermissions = permissions
|
||||
|
||||
val requiredAndroidPermissions = selectedPermissions.mapNotNull { permission ->
|
||||
webPermissionToAndroidPermission(permission)
|
||||
}
|
||||
|
||||
// When checkPermissions returns false, some of the required Android permissions will
|
||||
// have to be requested and the flow completes asynchronously via onPermissionResult
|
||||
if (checkPermissions(requiredAndroidPermissions, activity, activityResultLauncher)) {
|
||||
request.grant(selectedPermissions.toTypedArray())
|
||||
reset()
|
||||
}
|
||||
}
|
||||
|
||||
fun onPermissionResult(result: Map<String, Boolean>) {
|
||||
if (permissionRequest == null) {
|
||||
fatalError(
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue