Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/6616.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Support element call widget
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ private val DEFINED_TYPES by lazy {
WidgetType.StickerPicker,
WidgetType.Grafana,
WidgetType.Custom,
WidgetType.IntegrationManager
WidgetType.IntegrationManager,
WidgetType.ElementCall

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we have a trailing comma for the last item?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Of course, done.

)
}

Expand All @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion vector/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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" />
Expand Down
27 changes: 27 additions & 0 deletions vector/src/main/java/im/vector/app/core/utils/PermissionsTools.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package im.vector.app.core.utils
import android.Manifest
import android.app.Activity
import android.content.pm.PackageManager
import android.webkit.PermissionRequest
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
Expand Down Expand Up @@ -137,6 +138,32 @@ fun checkPermissions(
}
}

/**
* 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 checkWebViewPermissions(activity: Activity, request: PermissionRequest): Boolean {

@mnaturel mnaturel Jul 21, 2022

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we could extract this method into a dedicated use case such as CheckWebViewPermissionsUseCase? This way, it may be possible to inject the ApplicationContext instead of passing the activity as an argument.

We could also add unit tests on it as bonus.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, done.

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
}
}
}
}

/**
* To be call after the permission request.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,6 @@ sealed class RoomDetailAction : VectorViewModelAction {

// Live Location
object StopLiveLocationSharing : RoomDetailAction()

object OpenElementCallWidget : RoomDetailAction()
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,5 @@ sealed class RoomDetailViewEvents : VectorViewEvents {
data class StartChatEffect(val type: ChatEffect) : RoomDetailViewEvents()
object StopChatEffects : RoomDetailViewEvents()
object RoomReplacementStarted : RoomDetailViewEvents()
object OpenElementCallWidget : RoomDetailViewEvents()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,7 @@ class TimelineFragment @Inject constructor(
RoomDetailViewEvents.StopChatEffects -> handleStopChatEffects()
is RoomDetailViewEvents.DisplayAndAcceptCall -> acceptIncomingCall(it)
RoomDetailViewEvents.RoomReplacementStarted -> handleRoomReplacement()
RoomDetailViewEvents.OpenElementCallWidget -> handleOpenElementCallWidget()
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,5 @@ sealed class WidgetAction : VectorViewModelAction {
object DeleteWidget : WidgetAction()
object RevokeWidget : WidgetAction()
object OnTermsReviewed : WidgetAction()
object HangupElementCall : WidgetAction()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we rename the action CloseWidget to avoid introducing too specific action?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed.

}
121 changes: 101 additions & 20 deletions vector/src/main/java/im/vector/app/features/widgets/WidgetActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -40,6 +51,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 {
Expand Down Expand Up @@ -82,29 +97,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)
}
}

viewModel.onEach(WidgetViewState::status) { ws ->
when (ws) {
WidgetStatus.UNKNOWN -> {
} else {
permissionViewModel.observeViewEvents {
when (it) {
is RoomWidgetPermissionViewEvents.Close -> finish()
}
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)
}
}
}
}
Expand All @@ -119,6 +142,64 @@ class WidgetActivity : VectorBaseActivity<ActivityWidgetBinding>() {
}
}

override fun onUserLeaveHint() {
super.onUserLeaveHint()
val widgetArgs: WidgetArgs? = intent?.extras?.getParcelable(Mavericks.KEY_ARG)
if (widgetArgs?.kind == WidgetKind.ELEMENT_CALL) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am wondering if we should move if (widgetArgs?.kind == WidgetKind.ELEMENT_CALL) inside the enterPictureInPicture. Like this:

override fun onUserLeaveHint() {
    super.onUserLeaveHint()
    enterPictureInPicture()
}

private fun enterPictureInPicture() {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        val widgetArgs: WidgetArgs? = intent?.extras?.getParcelable(Mavericks.KEY_ARG)
        val params = when(widgetArgs?.kind) {
            WidgetKind.ELEMENT_CALL -> createElementCallPipParams()
                else -> null
        }
        params?.let {
           enterPictureInPictureMode(it)
       }
    }
}

This way we could easily add picture in picture for other widget in the future.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactored.

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.HangupElementCall)
}
}
}
}
registerReceiver(hangupBroadcastReceiver, IntentFilter(ACTION_MEDIA_CONTROL))
} else {
unregisterReceiver(hangupBroadcastReceiver)
}
}

private fun handleClose(event: WidgetViewEvents.Close) {
if (event.content != null) {
val intent = createResultIntent(event.content)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ class WidgetFragment @Inject constructor(

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
views.widgetWebView.setupForWidget(this)
views.widgetWebView.setupForWidget(requireActivity(), this)
if (fragmentArgs.kind.isAdmin()) {
viewModel.getPostAPIMediator().setWebView(views.widgetWebView)
}
Expand Down Expand Up @@ -131,9 +131,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()
}
}
}

Expand Down Expand Up @@ -298,7 +300,8 @@ class WidgetFragment @Inject constructor(
request = request,
context = requireContext(),
activity = requireActivity(),
activityResultLauncher = permissionResultLauncher
activityResultLauncher = permissionResultLauncher,
autoApprove = fragmentArgs.kind == WidgetKind.ELEMENT_CALL
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,14 @@ class WidgetViewModel @AssistedInject constructor(
WidgetAction.DeleteWidget -> handleDeleteWidget()
WidgetAction.RevokeWidget -> handleRevokeWidget()
WidgetAction.OnTermsReviewed -> loadFormattedUrl(forceFetchToken = false)
WidgetAction.HangupElementCall -> handleHangupElementCall()
}
}

private fun handleHangupElementCall() {
_viewEvents.post(WidgetViewEvents.Close())
}

private fun handleRevokeWidget() {
viewModelScope.launch {
val widgetId = initialState.widgetId ?: return@launch
Expand Down
Loading