mirror of
https://github.com/SchildiChat/SchildiChat-android.git
synced 2024-11-25 19:05:56 +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.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 {
|
||||||
|
|
|
@ -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" />
|
||||||
|
|
|
@ -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
|
// Live Location
|
||||||
object StopLiveLocationSharing : RoomDetailAction()
|
object StopLiveLocationSharing : RoomDetailAction()
|
||||||
|
|
||||||
|
object OpenElementCallWidget : RoomDetailAction()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 -> {
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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))
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,6 +98,13 @@ class WidgetActivity : VectorBaseActivity<ActivityWidgetBinding>() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
permissionViewModel.observeViewEvents {
|
||||||
when (it) {
|
when (it) {
|
||||||
is RoomWidgetPermissionViewEvents.Close -> finish()
|
is RoomWidgetPermissionViewEvents.Close -> finish()
|
||||||
|
@ -109,6 +132,7 @@ class WidgetActivity : VectorBaseActivity<ActivityWidgetBinding>() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
viewModel.onEach(WidgetViewState::widgetName) { name ->
|
viewModel.onEach(WidgetViewState::widgetName) { name ->
|
||||||
supportActionBar?.title = name
|
supportActionBar?.title = name
|
||||||
|
@ -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)
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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,11 +133,13 @@ class WidgetFragment @Inject constructor(
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
super.onPause()
|
super.onPause()
|
||||||
|
if (fragmentArgs.kind != WidgetKind.ELEMENT_CALL) {
|
||||||
views.widgetWebView.let {
|
views.widgetWebView.let {
|
||||||
it.pauseTimers()
|
it.pauseTimers()
|
||||||
it.onPause()
|
it.onPause()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun getMenuRes() = R.menu.menu_widget
|
override fun getMenuRes() = R.menu.menu_widget
|
||||||
|
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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,10 +65,25 @@ 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)
|
||||||
|
}
|
||||||
|
.setNegativeButton(R.string.room_widget_resource_decline_permission) { _, _ ->
|
||||||
|
request.deny()
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onPermissionsSelected(
|
||||||
|
permissions: List<String>,
|
||||||
|
request: PermissionRequest,
|
||||||
|
activity: FragmentActivity,
|
||||||
|
activityResultLauncher: ActivityResultLauncher<Array<String>>,
|
||||||
|
) {
|
||||||
|
permissionRequest = request
|
||||||
|
selectedPermissions = permissions
|
||||||
|
|
||||||
val requiredAndroidPermissions = selectedPermissions.mapNotNull { permission ->
|
val requiredAndroidPermissions = selectedPermissions.mapNotNull { permission ->
|
||||||
webPermissionToAndroidPermission(permission)
|
webPermissionToAndroidPermission(permission)
|
||||||
|
@ -70,11 +96,6 @@ class WebviewPermissionUtils @Inject constructor(
|
||||||
reset()
|
reset()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.setNegativeButton(R.string.room_widget_resource_decline_permission) { _, _ ->
|
|
||||||
request.deny()
|
|
||||||
}
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onPermissionResult(result: Map<String, Boolean>) {
|
fun onPermissionResult(result: Map<String, Boolean>) {
|
||||||
if (permissionRequest == null) {
|
if (permissionRequest == null) {
|
||||||
|
|
|
@ -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,12 +61,18 @@ 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) {
|
||||||
|
if (checkWebViewPermissionsUseCase.execute(activity, request)) {
|
||||||
|
request.grant(request.resources)
|
||||||
|
} else {
|
||||||
eventListener.onPermissionRequest(request)
|
eventListener.onPermissionRequest(request)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
webViewClient = VectorWebViewClient(eventListener)
|
webViewClient = VectorWebViewClient(eventListener)
|
||||||
|
|
||||||
val cookieManager = CookieManager.getInstance()
|
val cookieManager = CookieManager.getInstance()
|
||||||
|
|
|
@ -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