Merge pull request #2575 from vector-im/feature/fga/voip_switch_call_and_transfer_init

Feature/fga/voip switch call and transfer init
This commit is contained in:
ganfra 2021-01-08 12:09:39 +01:00 committed by GitHub
commit 19fc4419c3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
85 changed files with 1465 additions and 480 deletions

View file

@ -35,7 +35,11 @@ data class MatrixConfiguration(
* Optional proxy to connect to the matrix servers
* You can create one using for instance Proxy(proxyType, InetSocketAddress.createUnresolved(hostname, port)
*/
val proxy: Proxy? = null
val proxy: Proxy? = null,
/**
* True to advertise support for call transfers to other parties on Matrix calls.
*/
val supportsCallTransfer: Boolean = false
) {
/**

View file

@ -17,6 +17,7 @@
package org.matrix.android.sdk.api.session.call
import org.matrix.android.sdk.api.session.room.model.call.CallCandidate
import org.matrix.android.sdk.api.session.room.model.call.CallCapabilities
import org.matrix.android.sdk.api.session.room.model.call.CallHangupContent
import org.matrix.android.sdk.api.session.room.model.call.SdpType
import org.matrix.android.sdk.api.util.Optional
@ -42,6 +43,8 @@ interface MxCall : MxCallDetail {
var opponentPartyId: Optional<String>?
var opponentVersion: Int
var capabilities: CallCapabilities?
var state: CallState
/**
@ -86,6 +89,11 @@ interface MxCall : MxCallDetail {
*/
fun sendLocalIceCandidateRemovals(candidates: List<CallCandidate>)
/**
* Send a m.call.replaces event to initiate call transfer.
*/
suspend fun transfer(targetUserId: String, targetRoomId: String?)
fun addListener(listener: StateListener)
fun removeListener(listener: StateListener)

View file

@ -72,6 +72,7 @@ object EventType {
const val CALL_NEGOTIATE = "m.call.negotiate"
const val CALL_REJECT = "m.call.reject"
const val CALL_HANGUP = "m.call.hangup"
const val CALL_REPLACES = "m.call.replaces"
// Key share events
const val ROOM_KEY_REQUEST = "m.room_key_request"

View file

@ -39,7 +39,11 @@ data class CallAnswerContent(
/**
* Required. The version of the VoIP specification this messages adheres to.
*/
@Json(name = "version") override val version: String?
@Json(name = "version") override val version: String?,
/**
* Capability advertisement.
*/
@Json(name = "capabilities") val capabilities: CallCapabilities? = null
): CallSignallingContent {
@JsonClass(generateAdapter = true)

View file

@ -0,0 +1,32 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* 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 org.matrix.android.sdk.api.session.room.model.call
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import org.matrix.android.sdk.api.extensions.orFalse
@JsonClass(generateAdapter = true)
data class CallCapabilities(
/**
* If set to true, states that the sender of the event supports the m.call.replaces event and therefore supports
* being transferred to another destination
*/
@Json(name = "m.call.transferee") val transferee: Boolean? = null
)
fun CallCapabilities?.supportCallTransfer() = this?.transferee.orFalse()

View file

@ -49,7 +49,11 @@ data class CallInviteContent(
/**
* The field should be added for all invites where the target is a specific user
*/
@Json(name = "invitee") val invitee: String? = null
@Json(name = "invitee") val invitee: String? = null,
/**
* Capability advertisement.
*/
@Json(name = "capabilities") val capabilities: CallCapabilities? = null
): CallSignallingContent {
@JsonClass(generateAdapter = true)

View file

@ -0,0 +1,82 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* 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 org.matrix.android.sdk.api.session.room.model.call
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* This event is sent to signal the intent of a participant in a call to replace the call with another,
* such that the other participant ends up in a call with a new user.
*/
@JsonClass(generateAdapter = true)
data class CallReplacesContent(
/**
* Required. The ID of the call this event relates to.
*/
@Json(name = "call_id") override val callId: String,
/**
* Required. ID to let user identify remote echo of their own events
*/
@Json(name = "party_id") override val partyId: String? = null,
/**
* An identifier for the call replacement itself, generated by the transferor.
*/
@Json(name = "replacement_id") val replacementId: String? = null,
/**
* Optional. If specified, the transferee client waits for an invite to this room and joins it
* (possibly waiting for user confirmation) and then continues the transfer in this room.
* If absent, the transferee contacts the Matrix User ID given in the target_user field in a room of its choosing.
*/
@Json(name = "target_room") val targerRoomId: String? = null,
/**
* An object giving information about the transfer target
*/
@Json(name = "target_user") val targetUser: TargetUser? = null,
/**
* If specified, gives the call ID for the transferee's client to use when placing the replacement call.
* Mutually exclusive with await_call
*/
@Json(name = "create_call") val createCall: String? = null,
/**
* If specified, gives the call ID that the transferee's client should wait for.
* Mutually exclusive with create_call.
*/
@Json(name = "await_call") val awaitCall: String? = null,
/**
* Required. The version of the VoIP specification this messages adheres to.
*/
@Json(name = "version") override val version: String?
): CallSignallingContent {
@JsonClass(generateAdapter = true)
data class TargetUser(
/**
* Required. The matrix user ID of the transfer target
*/
@Json(name = "id") val id: String,
/**
* Optional. The display name of the transfer target.
*/
@Json(name = "display_name") val displayName: String?,
/**
* Optional. The avatar URL of the transfer target.
*/
@Json(name = "avatar_url") val avatarUrl: String?
)
}

View file

@ -52,6 +52,8 @@ data class TimelineEvent(
}
}
val roomId = root.roomId ?: ""
val metadata = HashMap<String, Any>()
/**

View file

@ -24,6 +24,7 @@ import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.call.CallAnswerContent
import org.matrix.android.sdk.api.session.room.model.call.CallCandidatesContent
import org.matrix.android.sdk.api.session.room.model.call.CallCapabilities
import org.matrix.android.sdk.api.session.room.model.call.CallHangupContent
import org.matrix.android.sdk.api.session.room.model.call.CallInviteContent
import org.matrix.android.sdk.api.session.room.model.call.CallNegotiateContent
@ -185,6 +186,7 @@ internal class CallSignalingHandler @Inject constructor(private val activeCallHa
call.apply {
opponentPartyId = Optional.from(content.partyId)
opponentVersion = content.version?.let { BigDecimal(it).intValueExact() } ?: MxCall.VOIP_PROTO_VERSION
capabilities = content.capabilities ?: CallCapabilities()
}
callListenersDispatcher.onCallAnswerReceived(content)
}

View file

@ -16,12 +16,15 @@
package org.matrix.android.sdk.internal.session.call
import org.matrix.android.sdk.api.MatrixConfiguration
import org.matrix.android.sdk.api.session.call.MxCall
import org.matrix.android.sdk.api.session.room.model.call.CallCapabilities
import org.matrix.android.sdk.api.session.room.model.call.CallInviteContent
import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.internal.di.DeviceId
import org.matrix.android.sdk.internal.di.UserId
import org.matrix.android.sdk.internal.session.call.model.MxCallImpl
import org.matrix.android.sdk.internal.session.profile.GetProfileInfoTask
import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory
import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor
import java.math.BigDecimal
@ -32,6 +35,8 @@ internal class MxCallFactory @Inject constructor(
@DeviceId private val deviceId: String?,
private val localEchoEventFactory: LocalEchoEventFactory,
private val eventSenderProcessor: EventSenderProcessor,
private val matrixConfiguration: MatrixConfiguration,
private val getProfileInfoTask: GetProfileInfoTask,
@UserId private val userId: String
) {
@ -46,10 +51,13 @@ internal class MxCallFactory @Inject constructor(
opponentUserId = opponentUserId,
isVideoCall = content.isVideo(),
localEchoEventFactory = localEchoEventFactory,
eventSenderProcessor = eventSenderProcessor
eventSenderProcessor = eventSenderProcessor,
matrixConfiguration = matrixConfiguration,
getProfileInfoTask = getProfileInfoTask
).apply {
opponentPartyId = Optional.from(content.partyId)
opponentVersion = content.version?.let { BigDecimal(it).intValueExact() } ?: MxCall.VOIP_PROTO_VERSION
capabilities = content.capabilities ?: CallCapabilities()
}
}
@ -63,7 +71,9 @@ internal class MxCallFactory @Inject constructor(
opponentUserId = opponentUserId,
isVideoCall = isVideoCall,
localEchoEventFactory = localEchoEventFactory,
eventSenderProcessor = eventSenderProcessor
eventSenderProcessor = eventSenderProcessor,
matrixConfiguration = matrixConfiguration,
getProfileInfoTask = getProfileInfoTask
)
}
}

View file

@ -16,6 +16,7 @@
package org.matrix.android.sdk.internal.session.call.model
import org.matrix.android.sdk.api.MatrixConfiguration
import org.matrix.android.sdk.api.session.call.CallState
import org.matrix.android.sdk.api.session.call.MxCall
import org.matrix.android.sdk.api.session.events.model.Content
@ -24,20 +25,25 @@ import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.LocalEcho
import org.matrix.android.sdk.api.session.events.model.UnsignedData
import org.matrix.android.sdk.api.session.events.model.toContent
import org.matrix.android.sdk.api.session.profile.ProfileService
import org.matrix.android.sdk.api.session.room.model.call.CallAnswerContent
import org.matrix.android.sdk.api.session.room.model.call.CallCandidate
import org.matrix.android.sdk.api.session.room.model.call.CallCandidatesContent
import org.matrix.android.sdk.api.session.room.model.call.CallCapabilities
import org.matrix.android.sdk.api.session.room.model.call.CallHangupContent
import org.matrix.android.sdk.api.session.room.model.call.CallInviteContent
import org.matrix.android.sdk.api.session.room.model.call.CallNegotiateContent
import org.matrix.android.sdk.api.session.room.model.call.CallRejectContent
import org.matrix.android.sdk.api.session.room.model.call.CallReplacesContent
import org.matrix.android.sdk.api.session.room.model.call.CallSelectAnswerContent
import org.matrix.android.sdk.api.session.room.model.call.SdpType
import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.internal.session.call.DefaultCallSignalingService
import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor
import org.matrix.android.sdk.internal.session.profile.GetProfileInfoTask
import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory
import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor
import timber.log.Timber
import java.util.UUID
internal class MxCallImpl(
override val callId: String,
@ -48,11 +54,14 @@ internal class MxCallImpl(
override val isVideoCall: Boolean,
override val ourPartyId: String,
private val localEchoEventFactory: LocalEchoEventFactory,
private val eventSenderProcessor: EventSenderProcessor
private val eventSenderProcessor: EventSenderProcessor,
private val matrixConfiguration: MatrixConfiguration,
private val getProfileInfoTask: GetProfileInfoTask
) : MxCall {
override var opponentPartyId: Optional<String>? = null
override var opponentVersion: Int = MxCall.VOIP_PROTO_VERSION
override var capabilities: CallCapabilities? = null
override var state: CallState = CallState.Idle
set(value) {
@ -98,7 +107,8 @@ internal class MxCallImpl(
partyId = ourPartyId,
lifetime = DefaultCallSignalingService.CALL_TIMEOUT_MS,
offer = CallInviteContent.Offer(sdp = sdpString),
version = MxCall.VOIP_PROTO_VERSION.toString()
version = MxCall.VOIP_PROTO_VERSION.toString(),
capabilities = buildCapabilities()
)
.let { createEventAndLocalEcho(type = EventType.CALL_INVITE, roomId = roomId, content = it.toContent()) }
.also { eventSenderProcessor.postEvent(it) }
@ -158,7 +168,8 @@ internal class MxCallImpl(
callId = callId,
partyId = ourPartyId,
answer = CallAnswerContent.Answer(sdp = sdpString),
version = MxCall.VOIP_PROTO_VERSION.toString()
version = MxCall.VOIP_PROTO_VERSION.toString(),
capabilities = buildCapabilities()
)
.let { createEventAndLocalEcho(type = EventType.CALL_ANSWER, roomId = roomId, content = it.toContent()) }
.also { eventSenderProcessor.postEvent(it) }
@ -191,6 +202,31 @@ internal class MxCallImpl(
.also { eventSenderProcessor.postEvent(it) }
}
override suspend fun transfer(targetUserId: String, targetRoomId: String?) {
val profileInfoParams = GetProfileInfoTask.Params(targetUserId)
val profileInfo = try {
getProfileInfoTask.execute(profileInfoParams)
} catch (failure: Throwable) {
Timber.v("Fail fetching profile info of $targetUserId while transferring call")
null
}
CallReplacesContent(
callId = callId,
partyId = ourPartyId,
replacementId = UUID.randomUUID().toString(),
version = MxCall.VOIP_PROTO_VERSION.toString(),
targetUser = CallReplacesContent.TargetUser(
id = targetUserId,
displayName = profileInfo?.get(ProfileService.DISPLAY_NAME_KEY) as? String,
avatarUrl = profileInfo?.get(ProfileService.AVATAR_URL_KEY) as? String
),
targerRoomId = targetRoomId,
createCall = UUID.randomUUID().toString()
)
.let { createEventAndLocalEcho(type = EventType.CALL_REPLACES, roomId = roomId, content = it.toContent()) }
.also { eventSenderProcessor.postEvent(it) }
}
private fun createEventAndLocalEcho(localId: String = LocalEcho.createLocalEchoId(), type: String, roomId: String, content: Content): Event {
return Event(
roomId = roomId,
@ -203,4 +239,12 @@ internal class MxCallImpl(
)
.also { localEchoEventFactory.createLocalEcho(it) }
}
private fun buildCapabilities(): CallCapabilities? {
return if (matrixConfiguration.supportsCallTransfer) {
CallCapabilities(true)
} else {
null
}
}
}

View file

@ -49,8 +49,10 @@ internal class QueueMemento @Inject constructor(context: Context,
}
fun unTrack(task: QueuedTask) {
managedTaskInfos.remove(task)
persist()
synchronized(managedTaskInfos) {
managedTaskInfos.remove(task)
persist()
}
}
private fun persist() {
@ -64,19 +66,17 @@ internal class QueueMemento @Inject constructor(context: Context,
}
private fun toTaskInfo(task: QueuedTask, order: Int): TaskInfo? {
synchronized(managedTaskInfos) {
return when (task) {
is SendEventQueuedTask -> SendEventTaskInfo(
localEchoId = task.event.eventId ?: "",
encrypt = task.encrypt,
order = order
)
is RedactQueuedTask -> RedactEventTaskInfo(
redactionLocalEcho = task.redactionLocalEchoId,
order = order
)
else -> null
}
return when (task) {
is SendEventQueuedTask -> SendEventTaskInfo(
localEchoId = task.event.eventId ?: "",
encrypt = task.encrypt,
order = order
)
is RedactQueuedTask -> RedactEventTaskInfo(
redactionLocalEcho = task.redactionLocalEchoId,
order = order
)
else -> null
}
}
@ -90,7 +90,7 @@ internal class QueueMemento @Inject constructor(context: Context,
?.forEach { info ->
try {
when (info) {
is SendEventTaskInfo -> {
is SendEventTaskInfo -> {
localEchoRepository.getUpToDateEcho(info.localEchoId)?.let {
if (it.sendState.isSending() && it.eventId != null && it.roomId != null) {
localEchoRepository.updateSendState(it.eventId, it.roomId, SendState.UNSENT)

View file

@ -243,6 +243,7 @@
<activity android:name=".features.pin.PinActivity" />
<activity android:name=".features.home.room.detail.search.SearchActivity" />
<activity android:name=".features.usercode.UserCodeActivity" />
<activity android:name=".features.call.transfer.CallTransferActivity" />
<!-- Services -->

View file

@ -28,6 +28,7 @@ import im.vector.app.features.MainActivity
import im.vector.app.features.call.CallControlsBottomSheet
import im.vector.app.features.call.VectorCallActivity
import im.vector.app.features.call.conference.VectorJitsiActivity
import im.vector.app.features.call.transfer.CallTransferActivity
import im.vector.app.features.createdirect.CreateDirectRoomActivity
import im.vector.app.features.crypto.keysbackup.settings.KeysBackupManageActivity
import im.vector.app.features.crypto.quads.SharedSecureStorageActivity
@ -146,6 +147,7 @@ interface ScreenComponent {
fun inject(activity: VectorJitsiActivity)
fun inject(activity: SearchActivity)
fun inject(activity: UserCodeActivity)
fun inject(activity: CallTransferActivity)
/* ==========================================================================================
* BottomSheets

View file

@ -38,6 +38,7 @@ import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.HomeRoomListDataSource
import im.vector.app.features.home.room.detail.RoomDetailPendingActionStore
import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider
import im.vector.app.features.home.room.detail.timeline.helper.RoomSummariesHolder
import im.vector.app.features.html.EventHtmlRenderer
import im.vector.app.features.html.VectorHtmlCompressor
import im.vector.app.features.login.ReAuthHelper
@ -158,6 +159,8 @@ interface VectorComponent {
fun webRtcCallManager(): WebRtcCallManager
fun roomSummaryHolder(): RoomSummariesHolder
@Component.Factory
interface Factory {
fun create(@BindsInstance context: Context): VectorComponent

View file

@ -22,7 +22,7 @@ import dagger.Binds
import dagger.Module
import dagger.multibindings.IntoMap
import im.vector.app.core.platform.ConfigurationViewModel
import im.vector.app.features.call.SharedActiveCallViewModel
import im.vector.app.features.call.SharedKnownCallsViewModel
import im.vector.app.features.crypto.keysbackup.restore.KeysBackupRestoreFromKeyViewModel
import im.vector.app.features.crypto.keysbackup.restore.KeysBackupRestoreFromPassphraseViewModel
import im.vector.app.features.crypto.keysbackup.restore.KeysBackupRestoreSharedViewModel
@ -85,8 +85,8 @@ interface ViewModelModule {
@Binds
@IntoMap
@ViewModelKey(SharedActiveCallViewModel::class)
fun bindSharedActiveCallViewModel(viewModel: SharedActiveCallViewModel): ViewModel
@ViewModelKey(SharedKnownCallsViewModel::class)
fun bindSharedActiveCallViewModel(viewModel: SharedKnownCallsViewModel): ViewModel
@Binds
@IntoMap

View file

@ -17,10 +17,18 @@
package im.vector.app.core.extensions
// Create a new Set including the provided element if not already present, or removing the element if already present
fun <T> Set<T>.toggle(element: T): Set<T> {
fun <T> Set<T>.toggle(element: T, singleElement: Boolean = false): Set<T> {
return if (contains(element)) {
minus(element)
if (singleElement) {
emptySet()
} else {
minus(element)
}
} else {
plus(element)
if (singleElement) {
setOf(element)
} else {
plus(element)
}
}
}

View file

@ -42,6 +42,7 @@ import androidx.lifecycle.ViewModelProvider
import androidx.viewbinding.ViewBinding
import com.bumptech.glide.util.Util
import com.google.android.material.snackbar.Snackbar
import com.jakewharton.rxbinding3.view.clicks
import im.vector.app.BuildConfig
import im.vector.app.R
import im.vector.app.core.di.ActiveSessionHolder
@ -84,6 +85,7 @@ import io.reactivex.disposables.Disposable
import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.failure.GlobalError
import timber.log.Timber
import java.util.concurrent.TimeUnit
import kotlin.system.measureTimeMillis
abstract class VectorBaseActivity<VB: ViewBinding> : AppCompatActivity(), HasScreenInjector {
@ -113,6 +115,18 @@ abstract class VectorBaseActivity<VB: ViewBinding> : AppCompatActivity(), HasScr
.disposeOnDestroy()
}
/* ==========================================================================================
* Views
* ========================================================================================== */
protected fun View.debouncedClicks(onClicked: () -> Unit) {
clicks()
.throttleFirst(300, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { onClicked() }
.disposeOnDestroy()
}
/* ==========================================================================================
* DATA
* ========================================================================================== */

View file

@ -22,6 +22,7 @@ import android.content.Intent
import android.os.Binder
import android.support.v4.media.session.MediaSessionCompat
import android.view.KeyEvent
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.media.session.MediaButtonReceiver
import com.airbnb.mvrx.MvRx
@ -46,7 +47,9 @@ import timber.log.Timber
class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListener, BluetoothHeadsetReceiver.EventListener {
private val connections = mutableMapOf<String, CallConnection>()
private val knownCalls = mutableSetOf<String>()
private lateinit var notificationManager: NotificationManagerCompat
private lateinit var notificationUtils: NotificationUtils
private lateinit var callManager: WebRtcCallManager
private lateinit var avatarRenderer: AvatarRenderer
@ -74,6 +77,7 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
override fun onCreate() {
super.onCreate()
notificationManager = NotificationManagerCompat.from(this)
notificationUtils = vectorComponent().notificationUtils()
callManager = vectorComponent().webRtcCallManager()
avatarRenderer = vectorComponent().avatarRenderer()
@ -130,7 +134,6 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
callRingPlayerOutgoing?.stop()
displayCallInProgressNotification(intent)
}
ACTION_NO_ACTIVE_CALL -> hideCallNotifications()
ACTION_CALL_CONNECTING -> {
// lower notification priority
displayCallInProgressNotification(intent)
@ -138,9 +141,8 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
callRingPlayerIncoming?.stop()
callRingPlayerOutgoing?.stop()
}
ACTION_ONGOING_CALL_BG -> {
// there is an ongoing call but call activity is in background
displayCallOnGoingInBackground(intent)
ACTION_CALL_TERMINATED -> {
handleCallTerminated(intent)
}
else -> {
// Should not happen
@ -166,11 +168,15 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
Timber.v("## VOIP displayIncomingCallNotification $intent")
val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: ""
val call = callManager.getCallById(callId) ?: return
if (knownCalls.contains(callId)) {
Timber.v("Call already notified $callId$")
return
}
val isVideoCall = call.mxCall.isVideoCall
val fromBg = intent.getBooleanExtra(EXTRA_IS_IN_BG, false)
val opponentMatrixItem = getOpponentMatrixItem(call)
Timber.v("displayIncomingCallNotification : display the dedicated notification")
val incomingCallAlert = IncomingCallAlert(INCOMING_CALL_ALERT_UID,
val incomingCallAlert = IncomingCallAlert(callId,
shouldBeDisplayedIn = { activity ->
if (activity is RoomDetailActivity) {
call.roomId != activity.currentRoomId
@ -195,7 +201,27 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
title = opponentMatrixItem?.getBestName() ?: call.mxCall.opponentUserId,
fromBg = fromBg
)
startForeground(NOTIFICATION_ID, notification)
if (knownCalls.isEmpty()) {
startForeground(callId.hashCode(), notification)
} else {
notificationManager.notify(callId.hashCode(), notification)
}
knownCalls.add(callId)
}
private fun handleCallTerminated(intent: Intent) {
val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: ""
if (!knownCalls.remove(callId)) {
Timber.v("Call terminated for unknown call $callId$")
return
}
val notification = notificationUtils.buildCallEndedNotification()
notificationManager.notify(callId.hashCode(), notification)
alertManager.cancelAlert(callId)
if (knownCalls.isEmpty()) {
mediaSession?.isActive = false
myStopSelf()
}
}
private fun showCallScreen(call: WebRtcCall, mode: String) {
@ -210,13 +236,22 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
private fun displayOutgoingRingingCallNotification(intent: Intent) {
val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: return
val call = callManager.getCallById(callId) ?: return
if (knownCalls.contains(callId)) {
Timber.v("Call already notified $callId$")
return
}
val opponentMatrixItem = getOpponentMatrixItem(call)
Timber.v("displayOutgoingCallNotification : display the dedicated notification")
val notification = notificationUtils.buildOutgoingRingingCallNotification(
mxCall = call.mxCall,
title = opponentMatrixItem?.getBestName() ?: call.mxCall.opponentUserId
)
startForeground(NOTIFICATION_ID, notification)
if (knownCalls.isEmpty()) {
startForeground(callId.hashCode(), notification)
} else {
notificationManager.notify(callId.hashCode(), notification)
}
knownCalls.add(callId)
}
/**
@ -226,44 +261,17 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
Timber.v("## VOIP displayCallInProgressNotification")
val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: ""
val call = callManager.getCallById(callId) ?: return
if (!knownCalls.contains(callId)) {
Timber.v("Call in progress for unknown call $callId$")
return
}
val opponentMatrixItem = getOpponentMatrixItem(call)
alertManager.cancelAlert(INCOMING_CALL_ALERT_UID)
alertManager.cancelAlert(callId)
val notification = notificationUtils.buildPendingCallNotification(
mxCall = call.mxCall,
title = opponentMatrixItem?.getBestName() ?: call.mxCall.opponentUserId
)
startForeground(NOTIFICATION_ID, notification)
// mCallIdInProgress = callId
}
/**
* Display a call in progress notification.
*/
private fun displayCallOnGoingInBackground(intent: Intent) {
Timber.v("## VOIP displayCallInProgressNotification")
val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: return
val call = callManager.getCallById(callId) ?: return
val opponentMatrixItem = getOpponentMatrixItem(call)
val notification = notificationUtils.buildPendingCallNotification(
mxCall = call.mxCall,
title = opponentMatrixItem?.getBestName() ?: call.mxCall.opponentUserId,
fromBg = true)
startForeground(NOTIFICATION_ID, notification)
// mCallIdInProgress = callId
}
/**
* Hide the permanent call notifications
*/
private fun hideCallNotifications() {
val notification = notificationUtils.buildCallEndedNotification()
alertManager.cancelAlert(INCOMING_CALL_ALERT_UID)
mediaSession?.isActive = false
// It's mandatory to startForeground to avoid crash
startForeground(NOTIFICATION_ID, notification)
myStopSelf()
notificationManager.notify(callId.hashCode(), notification)
}
fun addConnection(callConnection: CallConnection) {
@ -277,12 +285,11 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
companion object {
private const val NOTIFICATION_ID = 6480
private const val INCOMING_CALL_ALERT_UID = "INCOMING_CALL_ALERT_UID"
private const val ACTION_INCOMING_RINGING_CALL = "im.vector.app.core.services.CallService.ACTION_INCOMING_RINGING_CALL"
private const val ACTION_OUTGOING_RINGING_CALL = "im.vector.app.core.services.CallService.ACTION_OUTGOING_RINGING_CALL"
private const val ACTION_CALL_CONNECTING = "im.vector.app.core.services.CallService.ACTION_CALL_CONNECTING"
private const val ACTION_ONGOING_CALL = "im.vector.app.core.services.CallService.ACTION_ONGOING_CALL"
private const val ACTION_ONGOING_CALL_BG = "im.vector.app.core.services.CallService.ACTION_ONGOING_CALL_BG"
private const val ACTION_CALL_TERMINATED = "im.vector.app.core.services.CallService.ACTION_CALL_TERMINATED"
private const val ACTION_NO_ACTIVE_CALL = "im.vector.app.core.services.CallService.NO_ACTIVE_CALL"
// private const val ACTION_ACTIVITY_VISIBLE = "im.vector.app.core.services.CallService.ACTION_ACTIVITY_VISIBLE"
// private const val ACTION_STOP_RINGING = "im.vector.app.core.services.CallService.ACTION_STOP_RINGING"
@ -302,17 +309,6 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
ContextCompat.startForegroundService(context, intent)
}
fun onOnGoingCallBackground(context: Context,
callId: String) {
val intent = Intent(context, CallService::class.java)
.apply {
action = ACTION_ONGOING_CALL_BG
putExtra(EXTRA_CALL_ID, callId)
}
ContextCompat.startForegroundService(context, intent)
}
fun onOutgoingCallRinging(context: Context,
callId: String) {
val intent = Intent(context, CallService::class.java)
@ -335,10 +331,11 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
ContextCompat.startForegroundService(context, intent)
}
fun onNoActiveCall(context: Context) {
fun onCallTerminated(context: Context, callId: String) {
val intent = Intent(context, CallService::class.java)
.apply {
action = ACTION_NO_ACTIVE_CALL
action = ACTION_CALL_TERMINATED
putExtra(EXTRA_CALL_ID, callId)
}
ContextCompat.startForegroundService(context, intent)
}

View file

@ -20,9 +20,12 @@ import android.content.Context
import android.util.AttributeSet
import android.widget.RelativeLayout
import im.vector.app.R
import im.vector.app.databinding.ViewCurrentCallsBinding
import im.vector.app.features.call.webrtc.WebRtcCall
import im.vector.app.features.themes.ThemeUtils
import org.matrix.android.sdk.api.session.call.CallState
class ActiveCallView @JvmOverloads constructor(
class CurrentCallsView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
@ -32,15 +35,32 @@ class ActiveCallView @JvmOverloads constructor(
fun onTapToReturnToCall()
}
val views: ViewCurrentCallsBinding
var callback: Callback? = null
init {
setupView()
}
private fun setupView() {
inflate(context, R.layout.view_active_call_view, this)
inflate(context, R.layout.view_current_calls, this)
views = ViewCurrentCallsBinding.bind(this)
setBackgroundColor(ThemeUtils.getColor(context, R.attr.colorPrimary))
setOnClickListener { callback?.onTapToReturnToCall() }
}
fun render(calls: List<WebRtcCall>, formattedDuration: String) {
val connectedCalls = calls.filter {
it.mxCall.state is CallState.Connected
}
val heldCalls = connectedCalls.filter {
it.isLocalOnHold || it.remoteOnHold
}
if (connectedCalls.isEmpty()) return
views.currentCallsInfo.text = if (connectedCalls.size == heldCalls.size) {
resources.getQuantityString(R.plurals.call_only_paused, heldCalls.size, heldCalls.size)
} else {
if (heldCalls.isEmpty()) {
resources.getString(R.string.call_only_active, formattedDuration)
} else {
resources.getQuantityString(R.plurals.call_one_active_and_other_paused, heldCalls.size, formattedDuration, heldCalls.size)
}
}
}
}

View file

@ -16,7 +16,6 @@
package im.vector.app.core.ui.views
import android.view.View
import androidx.cardview.widget.CardView
import androidx.core.view.isVisible
import im.vector.app.core.utils.DebouncedClickListener
@ -26,33 +25,47 @@ import im.vector.app.features.call.webrtc.WebRtcCall
import org.webrtc.RendererCommon
import org.webrtc.SurfaceViewRenderer
class ActiveCallViewHolder {
class KnownCallsViewHolder {
private var activeCallPiP: SurfaceViewRenderer? = null
private var activeCallView: ActiveCallView? = null
private var currentCallsView: CurrentCallsView? = null
private var pipWrapper: CardView? = null
private var activeCall: WebRtcCall? = null
private var currentCall: WebRtcCall? = null
private var calls: List<WebRtcCall> = emptyList()
private var activeCallPipInitialized = false
fun updateCall(activeCall: WebRtcCall?) {
this.activeCall = activeCall
val hasActiveCall = activeCall?.mxCall?.state is CallState.Connected
private val tickListener = object : WebRtcCall.Listener {
override fun onTick(formattedDuration: String) {
currentCallsView?.render(calls, formattedDuration)
}
}
fun updateCall(currentCall: WebRtcCall?, calls: List<WebRtcCall>) {
activeCallPiP?.let {
this.currentCall?.detachRenderers(listOf(it))
}
this.currentCall?.removeListener(tickListener)
this.currentCall = currentCall
this.currentCall?.addListener(tickListener)
this.calls = calls
val hasActiveCall = currentCall?.mxCall?.state is CallState.Connected
if (hasActiveCall) {
val isVideoCall = activeCall?.mxCall?.isVideoCall == true
val isVideoCall = currentCall?.mxCall?.isVideoCall == true
if (isVideoCall) initIfNeeded()
activeCallView?.isVisible = !isVideoCall
currentCallsView?.isVisible = !isVideoCall
currentCallsView?.render(calls, currentCall?.formattedDuration() ?: "")
pipWrapper?.isVisible = isVideoCall
activeCallPiP?.isVisible = isVideoCall
activeCallPiP?.let {
activeCall?.attachViewRenderers(null, it, null)
currentCall?.attachViewRenderers(null, it, null)
}
} else {
activeCallView?.isVisible = false
currentCallsView?.isVisible = false
activeCallPiP?.isVisible = false
pipWrapper?.isVisible = false
activeCallPiP?.let {
activeCall?.detachRenderers(listOf(it))
currentCall?.detachRenderers(listOf(it))
}
}
}
@ -70,30 +83,31 @@ class ActiveCallViewHolder {
}
}
fun bind(activeCallPiP: SurfaceViewRenderer, activeCallView: ActiveCallView, pipWrapper: CardView, interactionListener: ActiveCallView.Callback) {
fun bind(activeCallPiP: SurfaceViewRenderer, activeCallView: CurrentCallsView, pipWrapper: CardView, interactionListener: CurrentCallsView.Callback) {
this.activeCallPiP = activeCallPiP
this.activeCallView = activeCallView
this.currentCallsView = activeCallView
this.pipWrapper = pipWrapper
this.activeCallView?.callback = interactionListener
this.currentCallsView?.callback = interactionListener
pipWrapper.setOnClickListener(
DebouncedClickListener(View.OnClickListener { _ ->
DebouncedClickListener({ _ ->
interactionListener.onTapToReturnToCall()
})
)
this.currentCall?.addListener(tickListener)
}
fun unBind() {
activeCallPiP?.let {
activeCall?.detachRenderers(listOf(it))
currentCall?.detachRenderers(listOf(it))
}
if (activeCallPipInitialized) {
activeCallPiP?.release()
}
this.activeCallView?.callback = null
this.currentCallsView?.callback = null
this.currentCall?.removeListener(tickListener)
pipWrapper?.setOnClickListener(null)
activeCallPiP = null
activeCallView = null
currentCallsView = null
pipWrapper = null
}
}

View file

@ -0,0 +1,57 @@
/*
* Copyright 2020 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 io.reactivex.Observable
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
class CountUpTimer(private val intervalInMs: Long) {
private val elapsedTime: AtomicLong = AtomicLong()
private val resumed: AtomicBoolean = AtomicBoolean(false)
private val disposable = Observable.interval(intervalInMs, TimeUnit.MILLISECONDS)
.filter { _ -> resumed.get() }
.doOnNext { _ -> elapsedTime.addAndGet(intervalInMs) }
.subscribe {
tickListener?.onTick(elapsedTime.get())
}
var tickListener: TickListener? = null
fun elapsedTime(): Long {
return elapsedTime.get()
}
fun pause() {
resumed.set(false)
}
fun resume() {
resumed.set(true)
}
fun stop() {
disposable.dispose()
}
interface TickListener {
fun onTick(milliseconds: Long)
}
}

View file

@ -63,6 +63,11 @@ class CallControlsBottomSheet : VectorBaseBottomSheetDialogFragment<BottomSheetC
dismiss()
}
views.callControlsTransfer.views.itemVerificationClickableZone.debouncedClicks {
callViewModel.handle(VectorCallViewActions.InitiateCallTransfer)
dismiss()
}
callViewModel.observeViewEvents {
when (it) {
is VectorCallViewEvents.ShowSoundDeviceChooser -> {
@ -153,5 +158,6 @@ class CallControlsBottomSheet : VectorBaseBottomSheetDialogFragment<BottomSheetC
views.callControlsToggleHoldResume.subTitle = null
views.callControlsToggleHoldResume.leftIcon = ContextCompat.getDrawable(requireContext(), R.drawable.ic_call_hold_action)
}
views.callControlsTransfer.isVisible = state.canOpponentBeTransferred
}
}

View file

@ -23,37 +23,51 @@ import im.vector.app.features.call.webrtc.WebRtcCallManager
import org.matrix.android.sdk.api.session.call.MxCall
import javax.inject.Inject
class SharedActiveCallViewModel @Inject constructor(
class SharedKnownCallsViewModel @Inject constructor(
private val callManager: WebRtcCallManager
) : ViewModel() {
val activeCall: MutableLiveData<WebRtcCall?> = MutableLiveData()
val liveKnownCalls: MutableLiveData<List<WebRtcCall>> = MutableLiveData()
val callStateListener = object : WebRtcCall.Listener {
val callListener = object : WebRtcCall.Listener {
override fun onStateUpdate(call: MxCall) {
if (activeCall.value?.callId == call.callId) {
activeCall.postValue(callManager.getCallById(call.callId))
// post it-self
liveKnownCalls.postValue(liveKnownCalls.value)
}
override fun onHoldUnhold() {
super.onHoldUnhold()
// post it-self
liveKnownCalls.postValue(liveKnownCalls.value)
}
}
private val currentCallListener = object : WebRtcCallManager.CurrentCallListener {
override fun onCurrentCallChange(call: WebRtcCall?) {
val knownCalls = callManager.getCalls()
liveKnownCalls.postValue(knownCalls)
knownCalls.forEach {
it.removeListener(callListener)
it.addListener(callListener)
}
}
}
private val listener = object : WebRtcCallManager.CurrentCallListener {
override fun onCurrentCallChange(call: WebRtcCall?) {
activeCall.value?.mxCall?.removeListener(callStateListener)
activeCall.postValue(call)
call?.addListener(callStateListener)
init {
val knownCalls = callManager.getCalls()
liveKnownCalls.postValue(knownCalls)
callManager.addCurrentCallListener(currentCallListener)
knownCalls.forEach {
it.addListener(callListener)
}
}
init {
activeCall.postValue(callManager.currentCall)
callManager.addCurrentCallListener(listener)
}
override fun onCleared() {
activeCall.value?.removeListener(callStateListener)
callManager.removeCurrentCallListener(listener)
callManager.getCalls().forEach {
it.removeListener(callListener)
}
callManager.removeCurrentCallListener(currentCallListener)
super.onCleared()
}
}

View file

@ -34,10 +34,10 @@ import androidx.core.view.isVisible
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.MvRx
import com.airbnb.mvrx.viewModel
import com.airbnb.mvrx.withState
import im.vector.app.R
import im.vector.app.core.di.ScreenComponent
import im.vector.app.core.platform.VectorBaseActivity
import im.vector.app.core.services.CallService
import im.vector.app.core.utils.PERMISSIONS_FOR_AUDIO_IP_CALL
import im.vector.app.core.utils.PERMISSIONS_FOR_VIDEO_IP_CALL
import im.vector.app.core.utils.allGranted
@ -50,6 +50,7 @@ import im.vector.app.features.home.room.detail.RoomDetailActivity
import im.vector.app.features.home.room.detail.RoomDetailArgs
import io.reactivex.android.schedulers.AndroidSchedulers
import kotlinx.parcelize.Parcelize
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.call.CallState
import org.matrix.android.sdk.api.session.call.MxCallDetail
import org.matrix.android.sdk.api.session.call.MxPeerConnectionState
@ -106,7 +107,6 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
callArgs = intent.getParcelableExtra(MvRx.KEY_ARG)!!
} else {
Timber.e("## VOIP missing callArgs for VectorCall Activity")
CallService.onNoActiveCall(this)
finish()
}
@ -153,8 +153,6 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
private fun renderState(state: VectorCallViewState) {
Timber.v("## VOIP renderState call $state")
if (state.callState is Fail) {
// be sure to clear notification
CallService.onNoActiveCall(this)
finish()
return
}
@ -167,7 +165,8 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
views.smallIsHeldIcon.isVisible = false
when (callState) {
is CallState.Idle,
is CallState.Dialing -> {
is CallState.CreateOffer,
is CallState.Dialing -> {
views.callVideoGroup.isInvisible = true
views.callInfoGroup.isVisible = true
views.callStatusText.setText(R.string.call_ring)
@ -190,7 +189,7 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
}
is CallState.Connected -> {
if (callState.iceConnectionState == MxPeerConnectionState.CONNECTED) {
if (state.isLocalOnHold) {
if (state.isLocalOnHold || state.isRemoteOnHold) {
views.smallIsHeldIcon.isVisible = true
views.callVideoGroup.isInvisible = true
views.callInfoGroup.isVisible = true
@ -202,16 +201,17 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
views.callStatusText.setText(R.string.call_held_by_you)
} else {
views.callActionText.isInvisible = true
state.otherUserMatrixItem.invoke()?.let {
state.callInfo.otherUserItem?.let {
views.callStatusText.text = getString(R.string.call_held_by_user, it.getBestName())
}
}
} else {
views.callStatusText.text = null
views.callStatusText.text = state.formattedDuration
if (callArgs.isVideoCall) {
views.callVideoGroup.isVisible = true
views.callInfoGroup.isVisible = false
//views.pip_video_view.isVisible = !state.isVideoCaptureInError
views.pipRenderer.isVisible = !state.isVideoCaptureInError && state.otherKnownCallInfo == null
configureCallInfo(state)
} else {
views.callVideoGroup.isInvisible = true
views.callInfoGroup.isVisible = true
@ -226,8 +226,6 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
views.callStatusText.setText(R.string.call_connecting)
views.callConnectingProgress.isVisible = true
}
// ensure all attached?
callManager.getCallById(callArgs.callId)?.attachViewRenderers(views.pipRenderer, views.fullscreenRenderer, null)
}
is CallState.Terminated -> {
finish()
@ -238,7 +236,7 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
}
private fun configureCallInfo(state: VectorCallViewState, blurAvatar: Boolean = false) {
state.otherUserMatrixItem.invoke()?.let {
state.callInfo.otherUserItem?.let {
val colorFilter = ContextCompat.getColor(this, R.color.bg_call_screen)
avatarRenderer.renderBlur(it, views.bgCallView, sampling = 20, rounded = false, colorFilter = colorFilter)
views.participantNameText.text = it.getBestName()
@ -248,10 +246,32 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
avatarRenderer.render(it, views.otherMemberAvatar)
}
}
if (state.otherKnownCallInfo?.otherUserItem == null) {
views.otherKnownCallLayout.isVisible = false
} else {
val otherCall = callManager.getCallById(state.otherKnownCallInfo.callId)
val colorFilter = ContextCompat.getColor(this, R.color.bg_call_screen)
avatarRenderer.renderBlur(
matrixItem = state.otherKnownCallInfo.otherUserItem,
imageView = views.otherKnownCallAvatarView,
sampling = 20,
rounded = false,
colorFilter = colorFilter
)
views.otherKnownCallLayout.isVisible = true
views.otherSmallIsHeldIcon.isVisible = otherCall?.let { it.isLocalOnHold || it.remoteOnHold }.orFalse()
}
}
private fun configureCallViews() {
views.callControlsView.interactionListener = this
views.otherKnownCallAvatarView.setOnClickListener {
withState(callViewModel) {
val otherCall = callManager.getCallById(it.otherKnownCallInfo?.callId ?: "") ?: return@withState
startActivity(newIntent(this, otherCall.mxCall, null))
finish()
}
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
@ -295,12 +315,14 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
Timber.v("## VOIP handleViewEvents $event")
when (event) {
VectorCallViewEvents.DismissNoCall -> {
CallService.onNoActiveCall(this)
finish()
}
is VectorCallViewEvents.ConnectionTimeout -> {
onErrorTimoutConnect(event.turn)
}
is VectorCallViewEvents.ShowCallTransferScreen -> {
navigator.openCallTransfer(this, callArgs.callId)
}
null -> {
}
}

View file

@ -30,4 +30,5 @@ sealed class VectorCallViewActions : VectorViewModelAction {
object HeadSetButtonPressed : VectorCallViewActions()
object ToggleCamera : VectorCallViewActions()
object ToggleHDSD : VectorCallViewActions()
object InitiateCallTransfer : VectorCallViewActions()
}

View file

@ -27,6 +27,7 @@ sealed class VectorCallViewEvents : VectorViewEvents {
val available: List<CallAudioManager.SoundDevice>,
val current: CallAudioManager.SoundDevice
) : VectorCallViewEvents()
object ShowCallTransferScreen: VectorCallViewEvents()
// data class CallAnswered(val content: CallAnswerContent) : VectorCallViewEvents()
// data class CallHangup(val content: CallHangupContent) : VectorCallViewEvents()
// object CallAccepted : VectorCallViewEvents()

View file

@ -20,7 +20,6 @@ import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.Uninitialized
import com.airbnb.mvrx.ViewModelContext
import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject
@ -34,6 +33,7 @@ import org.matrix.android.sdk.api.session.call.CallState
import org.matrix.android.sdk.api.session.call.MxCall
import org.matrix.android.sdk.api.session.call.MxPeerConnectionState
import org.matrix.android.sdk.api.session.call.TurnServerResponse
import org.matrix.android.sdk.api.session.room.model.call.supportCallTransfer
import org.matrix.android.sdk.api.util.MatrixItem
import org.matrix.android.sdk.api.util.toMatrixItem
import java.util.Timer
@ -56,7 +56,7 @@ class VectorCallViewModel @AssistedInject constructor(
override fun onHoldUnhold() {
setState {
copy(
isLocalOnHold = call?.isLocalOnHold() ?: false,
isLocalOnHold = call?.isLocalOnHold ?: false,
isRemoteOnHold = call?.remoteOnHold ?: false
)
}
@ -80,6 +80,12 @@ class VectorCallViewModel @AssistedInject constructor(
}
}
override fun onTick(formattedDuration: String) {
setState {
copy(formattedDuration = formattedDuration)
}
}
override fun onStateUpdate(call: MxCall) {
val callState = call.state
if (callState is CallState.Connected && callState.iceConnectionState == MxPeerConnectionState.CONNECTED) {
@ -109,7 +115,8 @@ class VectorCallViewModel @AssistedInject constructor(
}
setState {
copy(
callState = Success(callState)
callState = Success(callState),
canOpponentBeTransferred = call.capabilities.supportCallTransfer()
)
}
}
@ -118,10 +125,10 @@ class VectorCallViewModel @AssistedInject constructor(
private val currentCallListener = object : WebRtcCallManager.CurrentCallListener {
override fun onCurrentCallChange(call: WebRtcCall?) {
// we need to check the state
if (call == null) {
// we should dismiss, e.g handled by other session?
_viewEvents.post(VectorCallViewEvents.DismissNoCall)
} else {
updateOtherKnownCall(call)
}
}
@ -142,6 +149,20 @@ class VectorCallViewModel @AssistedInject constructor(
}
}
private fun updateOtherKnownCall(currentCall: WebRtcCall) {
val otherCall = callManager.getCalls().firstOrNull {
it.callId != currentCall.callId && it.mxCall.state is CallState.Connected
}
setState {
if (otherCall == null) {
copy(otherKnownCallInfo = null)
} else {
val otherUserItem: MatrixItem? = session.getUser(otherCall.mxCall.opponentUserId)?.toMatrixItem()
copy(otherKnownCallInfo = VectorCallViewState.CallInfo(otherCall.callId, otherUserItem))
}
}
}
init {
val webRtcCall = callManager.getCallById(initialState.callId)
if (webRtcCall == null) {
@ -161,14 +182,19 @@ class VectorCallViewModel @AssistedInject constructor(
copy(
isVideoCall = webRtcCall.mxCall.isVideoCall,
callState = Success(webRtcCall.mxCall.state),
otherUserMatrixItem = item?.let { Success(it) } ?: Uninitialized,
callInfo = VectorCallViewState.CallInfo(callId, item),
soundDevice = currentSoundDevice,
isLocalOnHold = webRtcCall.isLocalOnHold,
isRemoteOnHold = webRtcCall.remoteOnHold,
availableSoundDevices = callManager.callAudioManager.getAvailableSoundDevices(),
isFrontCamera = call?.currentCameraType() == CameraType.FRONT,
canSwitchCamera = call?.canSwitchCamera() ?: false,
isHD = webRtcCall.mxCall.isVideoCall && webRtcCall.currentCaptureFormat() is CaptureFormat.HD
isFrontCamera = webRtcCall.currentCameraType() == CameraType.FRONT,
canSwitchCamera = webRtcCall.canSwitchCamera(),
formattedDuration = webRtcCall.formattedDuration(),
isHD = webRtcCall.mxCall.isVideoCall && webRtcCall.currentCaptureFormat() is CaptureFormat.HD,
canOpponentBeTransferred = webRtcCall.mxCall.capabilities.supportCallTransfer()
)
}
updateOtherKnownCall(webRtcCall)
}
}
@ -246,6 +272,11 @@ class VectorCallViewModel @AssistedInject constructor(
if (!state.isVideoCall) return@withState
call?.setCaptureFormat(if (state.isHD) CaptureFormat.SD else CaptureFormat.HD)
}
VectorCallViewActions.InitiateCallTransfer -> {
_viewEvents.post(
VectorCallViewEvents.ShowCallTransferScreen
)
}
}.exhaustive
}

View file

@ -36,10 +36,18 @@ data class VectorCallViewState(
val canSwitchCamera: Boolean = true,
val soundDevice: CallAudioManager.SoundDevice = CallAudioManager.SoundDevice.PHONE,
val availableSoundDevices: List<CallAudioManager.SoundDevice> = emptyList(),
val otherUserMatrixItem: Async<MatrixItem> = Uninitialized,
val callState: Async<CallState> = Uninitialized
val callState: Async<CallState> = Uninitialized,
val otherKnownCallInfo: CallInfo? = null,
val callInfo: CallInfo = CallInfo(callId),
val formattedDuration: String = "",
val canOpponentBeTransferred: Boolean = false
) : MvRxState {
data class CallInfo(
val callId: String,
val otherUserItem: MatrixItem? = null
)
constructor(callArgs: CallArgs): this(
callId = callArgs.callId,
roomId = callArgs.roomId,

View file

@ -0,0 +1,23 @@
/*
* Copyright (c) 2020 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.features.call.transfer
import im.vector.app.core.platform.VectorViewModelAction
sealed class CallTransferAction : VectorViewModelAction {
data class Connect(val consultFirst: Boolean, val selectedUserId: String) : CallTransferAction()
}

View file

@ -0,0 +1,167 @@
/*
* Copyright (c) 2020 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.features.call.transfer
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.Parcelable
import android.widget.Toast
import com.airbnb.mvrx.MvRx
import com.airbnb.mvrx.viewModel
import im.vector.app.R
import im.vector.app.core.di.ScreenComponent
import im.vector.app.core.error.ErrorFormatter
import im.vector.app.core.extensions.addFragment
import im.vector.app.core.extensions.addFragmentToBackstack
import im.vector.app.core.platform.VectorBaseActivity
import im.vector.app.core.utils.PERMISSIONS_FOR_MEMBERS_SEARCH
import im.vector.app.core.utils.PERMISSION_REQUEST_CODE_READ_CONTACTS
import im.vector.app.core.utils.allGranted
import im.vector.app.core.utils.checkPermissions
import im.vector.app.databinding.ActivityCallTransferBinding
import im.vector.app.features.contactsbook.ContactsBookFragment
import im.vector.app.features.contactsbook.ContactsBookViewModel
import im.vector.app.features.contactsbook.ContactsBookViewState
import im.vector.app.features.userdirectory.UserListFragment
import im.vector.app.features.userdirectory.UserListFragmentArgs
import im.vector.app.features.userdirectory.UserListSharedAction
import im.vector.app.features.userdirectory.UserListSharedActionViewModel
import im.vector.app.features.userdirectory.UserListViewModel
import im.vector.app.features.userdirectory.UserListViewState
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
@Parcelize
data class CallTransferArgs(val callId: String) : Parcelable
private const val USER_LIST_FRAGMENT_TAG = "USER_LIST_FRAGMENT_TAG"
class CallTransferActivity : VectorBaseActivity<ActivityCallTransferBinding>(),
CallTransferViewModel.Factory,
UserListViewModel.Factory,
ContactsBookViewModel.Factory {
private lateinit var sharedActionViewModel: UserListSharedActionViewModel
@Inject lateinit var userListViewModelFactory: UserListViewModel.Factory
@Inject lateinit var callTransferViewModelFactory: CallTransferViewModel.Factory
@Inject lateinit var contactsBookViewModelFactory: ContactsBookViewModel.Factory
@Inject lateinit var errorFormatter: ErrorFormatter
private val callTransferViewModel: CallTransferViewModel by viewModel()
override fun getBinding() = ActivityCallTransferBinding.inflate(layoutInflater)
override fun injectWith(injector: ScreenComponent) {
super.injectWith(injector)
injector.inject(this)
}
override fun create(initialState: UserListViewState): UserListViewModel {
return userListViewModelFactory.create(initialState)
}
override fun create(initialState: CallTransferViewState): CallTransferViewModel {
return callTransferViewModelFactory.create(initialState)
}
override fun create(initialState: ContactsBookViewState): ContactsBookViewModel {
return contactsBookViewModelFactory.create(initialState)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
waitingView = views.waitingView.waitingView
sharedActionViewModel = viewModelProvider.get(UserListSharedActionViewModel::class.java)
sharedActionViewModel
.observe()
.subscribe { sharedAction ->
when (sharedAction) {
UserListSharedAction.OpenPhoneBook -> openPhoneBook()
// not exhaustive because it's a sharedAction
else -> {
}
}
}
.disposeOnDestroy()
if (isFirstCreation()) {
addFragment(
R.id.callTransferFragmentContainer,
UserListFragment::class.java,
UserListFragmentArgs(
title = "",
menuResId = -1,
singleSelection = true,
showInviteActions = false,
showToolbar = false
),
USER_LIST_FRAGMENT_TAG
)
}
callTransferViewModel.observeViewEvents {
when (it) {
is CallTransferViewEvents.Dismiss -> finish()
CallTransferViewEvents.Loading -> showWaitingView()
is CallTransferViewEvents.FailToTransfer -> showSnackbar(getString(R.string.call_transfer_failure))
}
}
configureToolbar(views.callTransferToolbar)
views.callTransferToolbar.title = getString(R.string.call_transfer_title)
setupConnectAction()
}
private fun setupConnectAction() {
views.callTransferConnectAction.debouncedClicks {
val userListFragment = supportFragmentManager.findFragmentByTag(USER_LIST_FRAGMENT_TAG) as? UserListFragment
val selectedUser = userListFragment?.getCurrentState()?.getSelectedMatrixId()?.firstOrNull()
if (selectedUser != null) {
val action = CallTransferAction.Connect(views.callTransferConsultCheckBox.isChecked, selectedUser)
callTransferViewModel.handle(action)
}
}
}
private fun openPhoneBook() {
// Check permission first
if (checkPermissions(PERMISSIONS_FOR_MEMBERS_SEARCH,
this,
PERMISSION_REQUEST_CODE_READ_CONTACTS,
0)) {
addFragmentToBackstack(R.id.callTransferFragmentContainer, ContactsBookFragment::class.java)
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (allGranted(grantResults)) {
if (requestCode == PERMISSION_REQUEST_CODE_READ_CONTACTS) {
doOnPostResume { addFragmentToBackstack(R.id.callTransferFragmentContainer, ContactsBookFragment::class.java) }
}
} else {
Toast.makeText(baseContext, R.string.missing_permissions_error, Toast.LENGTH_SHORT).show()
}
}
companion object {
fun newIntent(context: Context, callId: String): Intent {
return Intent(context, CallTransferActivity::class.java).also {
it.putExtra(MvRx.KEY_ARG, CallTransferArgs(callId))
}
}
}
}

View file

@ -0,0 +1,25 @@
/*
* Copyright (c) 2020 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.features.call.transfer
import im.vector.app.core.platform.VectorViewEvents
sealed class CallTransferViewEvents : VectorViewEvents {
object Dismiss : CallTransferViewEvents()
object Loading: CallTransferViewEvents()
object FailToTransfer : CallTransferViewEvents()
}

View file

@ -0,0 +1,90 @@
/*
* Copyright (c) 2020 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.features.call.transfer
import androidx.lifecycle.viewModelScope
import com.airbnb.mvrx.ActivityViewModelContext
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.call.webrtc.WebRtcCall
import im.vector.app.features.call.webrtc.WebRtcCallManager
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.session.call.CallState
import org.matrix.android.sdk.api.session.call.MxCall
class CallTransferViewModel @AssistedInject constructor(@Assisted initialState: CallTransferViewState,
callManager: WebRtcCallManager)
: VectorViewModel<CallTransferViewState, CallTransferAction, CallTransferViewEvents>(initialState) {
@AssistedInject.Factory
interface Factory {
fun create(initialState: CallTransferViewState): CallTransferViewModel
}
companion object : MvRxViewModelFactory<CallTransferViewModel, CallTransferViewState> {
@JvmStatic
override fun create(viewModelContext: ViewModelContext, state: CallTransferViewState): CallTransferViewModel? {
val activity: CallTransferActivity = (viewModelContext as ActivityViewModelContext).activity()
return activity.callTransferViewModelFactory.create(state)
}
}
private val call = callManager.getCallById(initialState.callId)
private val callListener = object : WebRtcCall.Listener {
override fun onStateUpdate(call: MxCall) {
if (call.state == CallState.Terminated) {
_viewEvents.post(CallTransferViewEvents.Dismiss)
}
}
}
init {
if (call == null) {
_viewEvents.post(CallTransferViewEvents.Dismiss)
} else {
call.addListener(callListener)
}
}
override fun onCleared() {
super.onCleared()
call?.removeListener(callListener)
}
override fun handle(action: CallTransferAction) {
when (action) {
is CallTransferAction.Connect -> transferCall(action)
}.exhaustive
}
private fun transferCall(action: CallTransferAction.Connect) {
viewModelScope.launch {
try {
_viewEvents.post(CallTransferViewEvents.Loading)
call?.mxCall?.transfer(action.selectedUserId, null)
_viewEvents.post(CallTransferViewEvents.Dismiss)
} catch (failure: Throwable) {
_viewEvents.post(CallTransferViewEvents.FailToTransfer)
}
}
}
}

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2020 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.features.call.transfer
import com.airbnb.mvrx.MvRxState
data class CallTransferViewState(
val callId: String
) : MvRxState {
constructor(args: CallTransferArgs) : this(callId = args.callId)
}

View file

@ -20,6 +20,7 @@ import android.content.Context
import android.hardware.camera2.CameraManager
import androidx.core.content.getSystemService
import im.vector.app.core.services.CallService
import im.vector.app.core.utils.CountUpTimer
import im.vector.app.features.call.CallAudioManager
import im.vector.app.features.call.CameraEventsHandlerAdapter
import im.vector.app.features.call.CameraProxy
@ -45,6 +46,7 @@ import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.call.CallState
import org.matrix.android.sdk.api.session.call.MxCall
import org.matrix.android.sdk.api.session.call.MxPeerConnectionState
import org.matrix.android.sdk.api.session.call.TurnServerResponse
import org.matrix.android.sdk.api.session.room.model.call.CallAnswerContent
import org.matrix.android.sdk.api.session.room.model.call.CallCandidatesContent
@ -53,6 +55,7 @@ import org.matrix.android.sdk.api.session.room.model.call.CallInviteContent
import org.matrix.android.sdk.api.session.room.model.call.CallNegotiateContent
import org.matrix.android.sdk.api.session.room.model.call.SdpType
import org.matrix.android.sdk.internal.util.awaitCallback
import org.threeten.bp.Duration
import org.webrtc.AudioSource
import org.webrtc.AudioTrack
import org.webrtc.Camera1Enumerator
@ -72,6 +75,7 @@ import org.webrtc.VideoSource
import org.webrtc.VideoTrack
import timber.log.Timber
import java.lang.ref.WeakReference
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.TimeUnit
import javax.inject.Provider
import kotlin.coroutines.CoroutineContext
@ -88,15 +92,18 @@ class WebRtcCall(val mxCall: MxCall,
private val dispatcher: CoroutineContext,
private val sessionProvider: Provider<Session?>,
private val peerConnectionFactoryProvider: Provider<PeerConnectionFactory?>,
private val onCallBecomeActive: (WebRtcCall) -> Unit,
private val onCallEnded: (WebRtcCall) -> Unit) : MxCall.StateListener {
interface Listener : MxCall.StateListener {
fun onCaptureStateChanged() {}
fun onCameraChanged() {}
fun onHoldUnhold() {}
fun onTick(formattedDuration: String) {}
override fun onStateUpdate(call: MxCall) {}
}
private val listeners = ArrayList<Listener>()
private val listeners = CopyOnWriteArrayList<Listener>()
fun addListener(listener: Listener) {
listeners.add(listener)
@ -128,10 +135,29 @@ class WebRtcCall(val mxCall: MxCall,
private var currentCaptureFormat: CaptureFormat = CaptureFormat.HD
private var cameraAvailabilityCallback: CameraManager.AvailabilityCallback? = null
private val timer = CountUpTimer(Duration.ofSeconds(1).toMillis()).apply {
tickListener = object : CountUpTimer.TickListener {
override fun onTick(milliseconds: Long) {
val formattedDuration = formatDuration(Duration.ofMillis(milliseconds))
listeners.forEach {
tryOrNull { it.onTick(formattedDuration) }
}
}
}
}
// Mute status
var micMuted = false
private set
var videoMuted = false
private set
var remoteOnHold = false
private set
var isLocalOnHold = false
private set
// This value is used to track localOnHold when changing remoteOnHold value
private var wasLocalOnHold = false
var offerSdp: CallInviteContent.Offer? = null
@ -204,6 +230,12 @@ class WebRtcCall(val mxCall: MxCall,
}
}
fun formattedDuration(): String {
return formatDuration(
Duration.ofMillis(timer.elapsedTime())
)
}
private fun createPeerConnection(turnServerResponse: TurnServerResponse?) {
val peerConnectionFactory = peerConnectionFactoryProvider.get() ?: return
val iceServers = mutableListOf<PeerConnection.IceServer>().apply {
@ -229,21 +261,9 @@ class WebRtcCall(val mxCall: MxCall,
fun attachViewRenderers(localViewRenderer: SurfaceViewRenderer?, remoteViewRenderer: SurfaceViewRenderer, mode: String?) {
Timber.v("## VOIP attachViewRenderers localRendeder $localViewRenderer / $remoteViewRenderer")
// this.localSurfaceRenderer = WeakReference(localViewRenderer)
// this.remoteSurfaceRenderer = WeakReference(remoteViewRenderer)
localSurfaceRenderers.addIfNeeded(localViewRenderer)
remoteSurfaceRenderers.addIfNeeded(remoteViewRenderer)
// The call is going to resume from background, we can reduce notif
mxCall
.takeIf { it.state is CallState.Connected }
?.let { mxCall ->
// Start background service with notification
CallService.onPendingCall(
context = context,
callId = mxCall.callId)
}
GlobalScope.launch(dispatcher) {
when (mode) {
VectorCallActivity.INCOMING_ACCEPT -> {
@ -275,7 +295,6 @@ class WebRtcCall(val mxCall: MxCall,
fun detachRenderers(renderers: List<SurfaceViewRenderer>?) {
Timber.v("## VOIP detachRenderers")
// currentCall?.localMediaStream?.let { currentCall?.peerConnection?.removeStream(it) }
if (renderers.isNullOrEmpty()) {
// remove all sinks
localSurfaceRenderers.forEach {
@ -295,18 +314,6 @@ class WebRtcCall(val mxCall: MxCall,
remoteVideoTrack?.removeSink(it)
}
}
if (remoteSurfaceRenderers.isEmpty()) {
// The call is going to continue in background, so ensure notification is visible
mxCall
.takeIf { it.state is CallState.Connected }
?.let { mxCall ->
// Start background service with notification
CallService.onOnGoingCallBackground(
context = context,
callId = mxCall.callId
)
}
}
}
private suspend fun setupOutgoingCall() = withContext(dispatcher) {
@ -328,6 +335,9 @@ class WebRtcCall(val mxCall: MxCall,
}
private suspend fun internalAcceptIncomingCall() = withContext(dispatcher) {
tryOrNull {
onCallBecomeActive(this@WebRtcCall)
}
val turnServerResponse = getTurnServer()
// Update service state
withContext(Dispatchers.Main) {
@ -526,7 +536,7 @@ class WebRtcCall(val mxCall: MxCall,
* rather than 'sendonly')
* @returns true if the other party has put us on hold
*/
fun isLocalOnHold(): Boolean {
private fun computeIsLocalOnHold(): Boolean {
if (mxCall.state !is CallState.Connected) return false
var callOnHold = true
// We consider a call to be on hold only if *all* the tracks are on hold
@ -540,17 +550,32 @@ class WebRtcCall(val mxCall: MxCall,
}
fun updateRemoteOnHold(onHold: Boolean) {
if (remoteOnHold == onHold) return
remoteOnHold = onHold
val direction = if (onHold) {
RtpTransceiver.RtpTransceiverDirection.INACTIVE
} else {
RtpTransceiver.RtpTransceiverDirection.SEND_RECV
GlobalScope.launch(dispatcher) {
if (remoteOnHold == onHold) return@launch
val direction: RtpTransceiver.RtpTransceiverDirection
if (onHold) {
wasLocalOnHold = isLocalOnHold
remoteOnHold = true
isLocalOnHold = true
direction = RtpTransceiver.RtpTransceiverDirection.INACTIVE
timer.pause()
} else {
remoteOnHold = false
isLocalOnHold = wasLocalOnHold
onCallBecomeActive(this@WebRtcCall)
direction = RtpTransceiver.RtpTransceiverDirection.SEND_RECV
if (!isLocalOnHold) {
timer.resume()
}
}
for (transceiver in peerConnection?.transceivers ?: emptyList()) {
transceiver.direction = direction
}
updateMuteStatus()
listeners.forEach {
tryOrNull { it.onHoldUnhold() }
}
}
for (transceiver in peerConnection?.transceivers ?: emptyList()) {
transceiver.direction = direction
}
updateMuteStatus()
}
fun muteCall(muted: Boolean) {
@ -628,7 +653,10 @@ class WebRtcCall(val mxCall: MxCall,
}
private fun release() {
listeners.clear()
mxCall.removeListener(this)
timer.stop()
timer.tickListener = null
videoCapturer?.stopCapture()
videoCapturer?.dispose()
videoCapturer = null
@ -683,7 +711,6 @@ class WebRtcCall(val mxCall: MxCall,
if (mxCall.state == CallState.Terminated) {
return
}
mxCall.state = CallState.Terminated
// Close tracks ASAP
localVideoTrack?.setEnabled(false)
localVideoTrack?.setEnabled(false)
@ -691,14 +718,17 @@ class WebRtcCall(val mxCall: MxCall,
val cameraManager = context.getSystemService<CameraManager>()!!
cameraManager.unregisterAvailabilityCallback(cameraAvailabilityCallback)
}
release()
val wasRinging = mxCall.state is CallState.LocalRinging
mxCall.state = CallState.Terminated
GlobalScope.launch(dispatcher) {
release()
}
onCallEnded(this)
if (originatedByMe) {
// send hang up event
if (mxCall.state is CallState.Connected) {
mxCall.hangUp(reason)
} else {
if (wasRinging) {
mxCall.reject()
} else {
mxCall.hangUp(reason)
}
}
}
@ -758,7 +788,7 @@ class WebRtcCall(val mxCall: MxCall,
Timber.i("Ignoring colliding negotiate event because we're impolite")
return@launch
}
val prevOnHold = isLocalOnHold()
val prevOnHold = computeIsLocalOnHold()
try {
val sdp = SessionDescription(type.asWebRTC(), sdpText)
peerConnection.awaitSetRemoteDescription(sdp)
@ -770,8 +800,15 @@ class WebRtcCall(val mxCall: MxCall,
} catch (failure: Throwable) {
Timber.e(failure, "Failed to complete negotiation")
}
val nowOnHold = isLocalOnHold()
val nowOnHold = computeIsLocalOnHold()
wasLocalOnHold = nowOnHold
if (prevOnHold != nowOnHold) {
isLocalOnHold = nowOnHold
if (nowOnHold) {
timer.pause()
} else {
timer.resume()
}
listeners.forEach {
tryOrNull { it.onHoldUnhold() }
}
@ -779,9 +816,26 @@ class WebRtcCall(val mxCall: MxCall,
}
}
private fun formatDuration(duration: Duration): String {
val hours = duration.seconds / 3600
val minutes = (duration.seconds % 3600) / 60
val seconds = duration.seconds % 60
return if (hours > 0) {
String.format("%d:%02d:%02d", hours, minutes, seconds)
} else {
String.format("%02d:%02d", minutes, seconds)
}
}
// MxCall.StateListener
override fun onStateUpdate(call: MxCall) {
val state = call.state
if (state is CallState.Connected && state.iceConnectionState == MxPeerConnectionState.CONNECTED) {
timer.resume()
} else {
timer.pause()
}
listeners.forEach {
tryOrNull { it.onStateUpdate(call) }
}

View file

@ -45,7 +45,10 @@ import org.webrtc.DefaultVideoDecoderFactory
import org.webrtc.DefaultVideoEncoderFactory
import org.webrtc.PeerConnectionFactory
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject
import javax.inject.Singleton
@ -63,11 +66,11 @@ class WebRtcCallManager @Inject constructor(
get() = activeSessionDataSource.currentValue?.orNull()
interface CurrentCallListener {
fun onCurrentCallChange(call: WebRtcCall?)
fun onCurrentCallChange(call: WebRtcCall?) {}
fun onAudioDevicesChange() {}
}
private val currentCallsListeners = emptyList<CurrentCallListener>().toMutableList()
private val currentCallsListeners = CopyOnWriteArrayList<CurrentCallListener>()
fun addCurrentCallListener(listener: CurrentCallListener) {
currentCallsListeners.add(listener)
}
@ -100,16 +103,20 @@ class WebRtcCallManager @Inject constructor(
isInBackground = true
}
var currentCall: WebRtcCall? = null
set(value) {
field = value
currentCallsListeners.forEach {
tryOrNull { it.onCurrentCallChange(value) }
}
/**
* The current call is the call we interacted with whatever his state (connected,resumed, held...)
* As soon as we interact with an other call, it replaces this one and put it on held if not already.
*/
var currentCall: AtomicReference<WebRtcCall?> = AtomicReference(null)
private fun AtomicReference<WebRtcCall?>.setAndNotify(newValue: WebRtcCall?) {
set(newValue)
currentCallsListeners.forEach {
tryOrNull { it.onCurrentCallChange(newValue) }
}
}
private val callsByCallId = HashMap<String, WebRtcCall>()
private val callsByRoomId = HashMap<String, ArrayList<WebRtcCall>>()
private val callsByCallId = ConcurrentHashMap<String, WebRtcCall>()
private val callsByRoomId = ConcurrentHashMap<String, MutableList<WebRtcCall>>()
fun getCallById(callId: String): WebRtcCall? {
return callsByCallId[callId]
@ -119,9 +126,17 @@ class WebRtcCallManager @Inject constructor(
return callsByRoomId[roomId] ?: emptyList()
}
fun getCurrentCall(): WebRtcCall? {
return currentCall.get()
}
fun getCalls(): List<WebRtcCall> {
return callsByCallId.values.toList()
}
fun headSetButtonTapped() {
Timber.v("## VOIP headSetButtonTapped")
val call = currentCall ?: return
val call = getCurrentCall() ?: return
if (call.mxCall.state is CallState.LocalRinging) {
// accept call
call.acceptIncomingCall()
@ -161,16 +176,26 @@ class WebRtcCallManager @Inject constructor(
.createPeerConnectionFactory()
}
private fun onCallActive(call: WebRtcCall) {
Timber.v("## VOIP WebRtcPeerConnectionManager onCall active: ${call.mxCall.callId}")
val currentCall = getCurrentCall().takeIf { it != call }
currentCall?.updateRemoteOnHold(onHold = true)
this.currentCall.setAndNotify(call)
}
private fun onCallEnded(call: WebRtcCall) {
Timber.v("## VOIP WebRtcPeerConnectionManager onCall ended: ${call.mxCall.callId}")
CallService.onNoActiveCall(context)
CallService.onCallTerminated(context, call.callId)
callAudioManager.stop()
currentCall = null
callsByCallId.remove(call.mxCall.callId)
callsByRoomId[call.mxCall.roomId]?.remove(call)
if (getCurrentCall() == call) {
val otherCall = getCalls().lastOrNull()
currentCall.setAndNotify(otherCall)
}
// This must be done in this thread
executor.execute {
if (currentCall == null) {
if (getCurrentCall() == null) {
Timber.v("## VOIP Dispose peerConnectionFactory as there is no need to keep one")
peerConnectionFactory?.dispose()
peerConnectionFactory = null
@ -181,12 +206,22 @@ class WebRtcCallManager @Inject constructor(
fun startOutgoingCall(signalingRoomId: String, otherUserId: String, isVideoCall: Boolean) {
Timber.v("## VOIP startOutgoingCall in room $signalingRoomId to $otherUserId isVideo $isVideoCall")
if (getCallsByRoomId(signalingRoomId).isNotEmpty()) {
Timber.w("## VOIP you already have a call in this room")
return
}
if (getCurrentCall() != null && getCurrentCall()?.mxCall?.state !is CallState.Connected || getCalls().size >= 2) {
Timber.w("## VOIP cannot start outgoing call")
// Just ignore, maybe we could answer from other session?
return
}
executor.execute {
createPeerConnectionFactoryIfNeeded()
}
getCurrentCall()?.updateRemoteOnHold(onHold = true)
val mxCall = currentSession?.callSignalingService()?.createOutgoingCall(signalingRoomId, otherUserId, isVideoCall) ?: return
createWebRtcCall(mxCall)
val webRtcCall = createWebRtcCall(mxCall)
currentCall.setAndNotify(webRtcCall)
callAudioManager.startForCall(mxCall)
CallService.onOutgoingCallRinging(
@ -199,10 +234,11 @@ class WebRtcCallManager @Inject constructor(
override fun onCallIceCandidateReceived(mxCall: MxCall, iceCandidatesContent: CallCandidatesContent) {
Timber.v("## VOIP onCallIceCandidateReceived for call ${mxCall.callId}")
if (currentCall?.mxCall?.callId != mxCall.callId) return Unit.also {
Timber.w("## VOIP ignore ice candidates from other call")
}
currentCall?.onCallIceCandidateReceived(iceCandidatesContent)
val call = callsByCallId[iceCandidatesContent.callId]
?: return Unit.also {
Timber.w("onCallIceCandidateReceived for non active call? ${iceCandidatesContent.callId}")
}
call.onCallIceCandidateReceived(iceCandidatesContent)
}
private fun createWebRtcCall(mxCall: MxCall): WebRtcCall {
@ -217,26 +253,22 @@ class WebRtcCallManager @Inject constructor(
peerConnectionFactory
},
sessionProvider = { currentSession },
onCallBecomeActive = this::onCallActive,
onCallEnded = this::onCallEnded
)
currentCall = webRtcCall
callsByCallId[mxCall.callId] = webRtcCall
callsByRoomId.getOrPut(mxCall.roomId) { ArrayList() }
callsByRoomId.getOrPut(mxCall.roomId) { ArrayList(1) }
.add(webRtcCall)
return webRtcCall
}
fun acceptIncomingCall() {
currentCall?.acceptIncomingCall()
}
fun endCall(originatedByMe: Boolean = true) {
currentCall?.endCall(originatedByMe)
fun endCallForRoom(roomId: String, originatedByMe: Boolean = true) {
callsByRoomId[roomId]?.forEach { it.endCall(originatedByMe) }
}
fun onWiredDeviceEvent(event: WiredHeadsetStateReceiver.HeadsetPlugEvent) {
Timber.v("## VOIP onWiredDeviceEvent $event")
currentCall ?: return
getCurrentCall() ?: return
// sometimes we received un-wanted unplugged...
callAudioManager.wiredStateChange(event)
}
@ -248,8 +280,12 @@ class WebRtcCallManager @Inject constructor(
override fun onCallInviteReceived(mxCall: MxCall, callInviteContent: CallInviteContent) {
Timber.v("## VOIP onCallInviteReceived callId ${mxCall.callId}")
if (currentCall != null) {
Timber.w("## VOIP receiving incoming call while already in call?")
if (getCallsByRoomId(mxCall.roomId).isNotEmpty()) {
Timber.w("## VOIP you already have a call in this room")
return
}
if ((getCurrentCall() != null && getCurrentCall()?.mxCall?.state !is CallState.Connected) || getCalls().size >= 2) {
Timber.w("## VOIP receiving incoming call but cannot handle it")
// Just ignore, maybe we could answer from other session?
return
}
@ -329,12 +365,12 @@ class WebRtcCallManager @Inject constructor(
override fun onCallManagedByOtherSession(callId: String) {
Timber.v("## VOIP onCallManagedByOtherSession: $callId")
currentCall = null
val webRtcCall = callsByCallId.remove(callId)
if (webRtcCall != null) {
callsByRoomId[webRtcCall.mxCall.roomId]?.remove(webRtcCall)
}
CallService.onNoActiveCall(context)
// TODO: handle this properly
CallService.onCallTerminated(context, callId)
// did we start background sync? so we should stop it
if (isInBackground) {

View file

@ -32,7 +32,7 @@ import im.vector.app.core.extensions.configureWith
import im.vector.app.core.extensions.hideKeyboard
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.databinding.FragmentContactsBookBinding
import im.vector.app.features.userdirectory.PendingInvitee
import im.vector.app.features.userdirectory.PendingSelection
import im.vector.app.features.userdirectory.UserListAction
import im.vector.app.features.userdirectory.UserListSharedAction
import im.vector.app.features.userdirectory.UserListSharedActionViewModel
@ -44,9 +44,9 @@ import java.util.concurrent.TimeUnit
import javax.inject.Inject
class ContactsBookFragment @Inject constructor(
val contactsBookViewModelFactory: ContactsBookViewModel.Factory,
private val contactsBookViewModelFactory: ContactsBookViewModel.Factory,
private val contactsBookController: ContactsBookController
) : VectorBaseFragment<FragmentContactsBookBinding>(), ContactsBookController.Callback {
) : VectorBaseFragment<FragmentContactsBookBinding>(), ContactsBookController.Callback, ContactsBookViewModel.Factory {
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentContactsBookBinding {
return FragmentContactsBookBinding.inflate(inflater, container, false)
@ -59,6 +59,10 @@ class ContactsBookFragment @Inject constructor(
private lateinit var sharedActionViewModel: UserListSharedActionViewModel
override fun create(initialState: ContactsBookViewState): ContactsBookViewModel {
return contactsBookViewModelFactory.create(initialState)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
sharedActionViewModel = activityViewModelProvider.get(UserListSharedActionViewModel::class.java)
@ -128,13 +132,13 @@ class ContactsBookFragment @Inject constructor(
override fun onMatrixIdClick(matrixId: String) {
view?.hideKeyboard()
viewModel.handle(UserListAction.SelectPendingInvitee(PendingInvitee.UserPendingInvitee(User(matrixId))))
viewModel.handle(UserListAction.AddPendingSelection(PendingSelection.UserPendingSelection(User(matrixId))))
sharedActionViewModel.post(UserListSharedAction.GoBack)
}
override fun onThreePidClick(threePid: ThreePid) {
view?.hideKeyboard()
viewModel.handle(UserListAction.SelectPendingInvitee(PendingInvitee.ThreePidPendingInvitee(threePid)))
viewModel.handle(UserListAction.AddPendingSelection(PendingSelection.ThreePidPendingSelection(threePid)))
sharedActionViewModel.post(UserListSharedAction.GoBack)
}
}

View file

@ -16,7 +16,6 @@
package im.vector.app.features.contactsbook
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.viewModelScope
import com.airbnb.mvrx.ActivityViewModelContext
import com.airbnb.mvrx.FragmentViewModelContext
@ -31,8 +30,6 @@ import im.vector.app.core.contacts.MappedContact
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.EmptyViewEvents
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.createdirect.CreateDirectRoomActivity
import im.vector.app.features.invite.InviteUsersToRoomActivity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.MatrixCallback
@ -56,17 +53,11 @@ class ContactsBookViewModel @AssistedInject constructor(@Assisted
companion object : MvRxViewModelFactory<ContactsBookViewModel, ContactsBookViewState> {
override fun create(viewModelContext: ViewModelContext, state: ContactsBookViewState): ContactsBookViewModel? {
return when (viewModelContext) {
is FragmentViewModelContext -> (viewModelContext.fragment() as ContactsBookFragment).contactsBookViewModelFactory.create(state)
is ActivityViewModelContext -> {
when (viewModelContext.activity<FragmentActivity>()) {
is CreateDirectRoomActivity -> viewModelContext.activity<CreateDirectRoomActivity>().contactsBookViewModelFactory.create(state)
is InviteUsersToRoomActivity -> viewModelContext.activity<InviteUsersToRoomActivity>().contactsBookViewModelFactory.create(state)
else -> error("Wrong activity or fragment")
}
}
else -> error("Wrong activity or fragment")
val factory = when (viewModelContext) {
is FragmentViewModelContext -> viewModelContext.fragment as? Factory
is ActivityViewModelContext -> viewModelContext.activity as? Factory
}
return factory?.create(state) ?: error("You should let your activity/fragment implements Factory interface")
}
}

View file

@ -17,11 +17,11 @@
package im.vector.app.features.createdirect
import im.vector.app.core.platform.VectorViewModelAction
import im.vector.app.features.userdirectory.PendingInvitee
import im.vector.app.features.userdirectory.PendingSelection
sealed class CreateDirectRoomAction : VectorViewModelAction {
data class CreateRoomAndInviteSelectedUsers(
val invitees: Set<PendingInvitee>,
val selections: Set<PendingSelection>,
val existingDmRoomId: String?
) : CreateDirectRoomAction()
}

View file

@ -45,6 +45,7 @@ import im.vector.app.core.utils.checkPermissions
import im.vector.app.core.utils.onPermissionDeniedSnackbar
import im.vector.app.features.contactsbook.ContactsBookFragment
import im.vector.app.features.contactsbook.ContactsBookViewModel
import im.vector.app.features.contactsbook.ContactsBookViewState
import im.vector.app.features.userdirectory.UserListFragment
import im.vector.app.features.userdirectory.UserListFragmentArgs
import im.vector.app.features.userdirectory.UserListSharedAction
@ -57,7 +58,7 @@ import org.matrix.android.sdk.api.session.room.failure.CreateRoomFailure
import java.net.HttpURLConnection
import javax.inject.Inject
class CreateDirectRoomActivity : SimpleFragmentActivity(), UserListViewModel.Factory {
class CreateDirectRoomActivity : SimpleFragmentActivity(), UserListViewModel.Factory, CreateDirectRoomViewModel.Factory, ContactsBookViewModel.Factory {
private val viewModel: CreateDirectRoomViewModel by viewModel()
private lateinit var sharedActionViewModel: UserListSharedActionViewModel
@ -71,9 +72,11 @@ class CreateDirectRoomActivity : SimpleFragmentActivity(), UserListViewModel.Fac
injector.inject(this)
}
override fun create(initialState: UserListViewState): UserListViewModel {
return userListViewModelFactory.create(initialState)
}
override fun create(initialState: UserListViewState) = userListViewModelFactory.create(initialState)
override fun create(initialState: CreateDirectRoomViewState) = createDirectRoomViewModelFactory.create(initialState)
override fun create(initialState: ContactsBookViewState) = contactsBookViewModelFactory.create(initialState)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -143,7 +146,7 @@ class CreateDirectRoomActivity : SimpleFragmentActivity(), UserListViewModel.Fac
private fun onMenuItemSelected(action: UserListSharedAction.OnMenuItemSelected) {
if (action.itemId == R.id.action_create_direct_room) {
viewModel.handle(CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers(
action.invitees,
action.selections,
null
))
}

View file

@ -29,8 +29,7 @@ import im.vector.app.core.utils.PERMISSIONS_FOR_TAKING_PHOTO
import im.vector.app.core.utils.checkPermissions
import im.vector.app.core.utils.registerForPermissionsResult
import im.vector.app.databinding.FragmentQrCodeScannerBinding
import im.vector.app.features.userdirectory.PendingInvitee
import im.vector.app.features.userdirectory.PendingSelection
import me.dm7.barcodescanner.zxing.ZXingScannerView
import org.matrix.android.sdk.api.session.permalinks.PermalinkData
import org.matrix.android.sdk.api.session.permalinks.PermalinkParser
@ -107,7 +106,7 @@ class CreateDirectRoomByQrCodeFragment @Inject constructor() : VectorBaseFragmen
val qrInvitee = if (viewModel.session.getUser(mxid) != null) viewModel.session.getUser(mxid)!! else User(mxid, null, null)
viewModel.handle(
CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers(setOf(PendingInvitee.UserPendingInvitee(qrInvitee)), existingDm)
CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers(setOf(PendingSelection.UserPendingSelection(qrInvitee)), existingDm)
)
}
}

View file

@ -18,6 +18,7 @@ package im.vector.app.features.createdirect
import androidx.lifecycle.viewModelScope
import com.airbnb.mvrx.ActivityViewModelContext
import com.airbnb.mvrx.FragmentViewModelContext
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.ViewModelContext
@ -27,7 +28,7 @@ import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.raw.wellknown.getElementWellknown
import im.vector.app.features.raw.wellknown.isE2EByDefault
import im.vector.app.features.userdirectory.PendingInvitee
import im.vector.app.features.userdirectory.PendingSelection
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.raw.RawService
@ -50,8 +51,11 @@ class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted
@JvmStatic
override fun create(viewModelContext: ViewModelContext, state: CreateDirectRoomViewState): CreateDirectRoomViewModel? {
val activity: CreateDirectRoomActivity = (viewModelContext as ActivityViewModelContext).activity()
return activity.createDirectRoomViewModelFactory.create(state)
val factory = when (viewModelContext) {
is FragmentViewModelContext -> viewModelContext.fragment as? Factory
is ActivityViewModelContext -> viewModelContext.activity as? Factory
}
return factory?.create(state) ?: error("You should let your activity/fragment implements Factory interface")
}
}
@ -72,11 +76,11 @@ class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted
}
} else {
// Create the DM
createRoomAndInviteSelectedUsers(action.invitees)
createRoomAndInviteSelectedUsers(action.selections)
}
}
private fun createRoomAndInviteSelectedUsers(invitees: Set<PendingInvitee>) {
private fun createRoomAndInviteSelectedUsers(selections: Set<PendingSelection>) {
viewModelScope.launch(Dispatchers.IO) {
val adminE2EByDefault = rawService.getElementWellknown(session.myUserId)
?.isE2EByDefault()
@ -84,10 +88,10 @@ class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted
val roomParams = CreateRoomParams()
.apply {
invitees.forEach {
selections.forEach {
when (it) {
is PendingInvitee.UserPendingInvitee -> invitedUserIds.add(it.user.userId)
is PendingInvitee.ThreePidPendingInvitee -> invite3pids.add(it.threePid)
is PendingSelection.UserPendingSelection -> invitedUserIds.add(it.user.userId)
is PendingSelection.ThreePidPendingSelection -> invite3pids.add(it.threePid)
}.exhaustive
}
setDirectMessage()

View file

@ -21,7 +21,6 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.lifecycle.Observer
import com.airbnb.mvrx.activityViewModel
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
@ -33,11 +32,11 @@ import im.vector.app.core.glide.GlideApp
import im.vector.app.core.platform.ToolbarConfigurable
import im.vector.app.core.platform.VectorBaseActivity
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.ui.views.ActiveCallView
import im.vector.app.core.ui.views.ActiveCallViewHolder
import im.vector.app.core.ui.views.CurrentCallsView
import im.vector.app.core.ui.views.KnownCallsViewHolder
import im.vector.app.core.ui.views.KeysBackupBanner
import im.vector.app.databinding.FragmentHomeDetailBinding
import im.vector.app.features.call.SharedActiveCallViewModel
import im.vector.app.features.call.SharedKnownCallsViewModel
import im.vector.app.features.call.VectorCallActivity
import im.vector.app.features.call.webrtc.WebRtcCallManager
import im.vector.app.features.home.room.list.RoomListFragment
@ -70,7 +69,7 @@ class HomeDetailFragment @Inject constructor(
private val vectorPreferences: VectorPreferences
) : VectorBaseFragment<FragmentHomeDetailBinding>(),
KeysBackupBanner.Delegate,
ActiveCallView.Callback,
CurrentCallsView.Callback,
ServerBackupStatusViewModel.Factory {
private val viewModel: HomeDetailViewModel by fragmentViewModel()
@ -78,18 +77,18 @@ class HomeDetailFragment @Inject constructor(
private val serverBackupStatusViewModel: ServerBackupStatusViewModel by activityViewModel()
private lateinit var sharedActionViewModel: HomeSharedActionViewModel
private lateinit var sharedCallActionViewModel: SharedActiveCallViewModel
private lateinit var sharedCallActionViewModel: SharedKnownCallsViewModel
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentHomeDetailBinding {
return FragmentHomeDetailBinding.inflate(inflater, container, false)
}
private val activeCallViewHolder = ActiveCallViewHolder()
private val activeCallViewHolder = KnownCallsViewHolder()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
sharedActionViewModel = activityViewModelProvider.get(HomeSharedActionViewModel::class.java)
sharedCallActionViewModel = activityViewModelProvider.get(SharedActiveCallViewModel::class.java)
sharedCallActionViewModel = activityViewModelProvider.get(SharedKnownCallsViewModel::class.java)
setupBottomNavigationView()
setupToolbar()
@ -127,9 +126,9 @@ class HomeDetailFragment @Inject constructor(
}
sharedCallActionViewModel
.activeCall
.observe(viewLifecycleOwner, Observer {
activeCallViewHolder.updateCall(it)
.liveKnownCalls
.observe(viewLifecycleOwner, {
activeCallViewHolder.updateCall(callManager.getCurrentCall(), callManager.getCalls())
invalidateOptionsMenu()
})
}
@ -336,7 +335,7 @@ class HomeDetailFragment @Inject constructor(
}
override fun onTapToReturnToCall() {
sharedCallActionViewModel.activeCall.value?.let { call ->
callManager.getCurrentCall()?.let { call ->
VectorCallActivity.newIntent(
context = requireContext(),
callId = call.callId,

View file

@ -89,8 +89,8 @@ import im.vector.app.core.intent.getFilenameFromUri
import im.vector.app.core.intent.getMimeTypeFromUri
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.resources.ColorProvider
import im.vector.app.core.ui.views.ActiveCallView
import im.vector.app.core.ui.views.ActiveCallViewHolder
import im.vector.app.core.ui.views.CurrentCallsView
import im.vector.app.core.ui.views.KnownCallsViewHolder
import im.vector.app.core.ui.views.ActiveConferenceView
import im.vector.app.core.ui.views.JumpToReadMarkerView
import im.vector.app.core.ui.views.NotificationAreaView
@ -120,7 +120,7 @@ import im.vector.app.features.attachments.ContactAttachment
import im.vector.app.features.attachments.preview.AttachmentsPreviewActivity
import im.vector.app.features.attachments.preview.AttachmentsPreviewArgs
import im.vector.app.features.attachments.toGroupedContentAttachmentData
import im.vector.app.features.call.SharedActiveCallViewModel
import im.vector.app.features.call.SharedKnownCallsViewModel
import im.vector.app.features.call.VectorCallActivity
import im.vector.app.features.call.conference.JitsiCallViewModel
import im.vector.app.features.call.webrtc.WebRtcCallManager
@ -227,7 +227,8 @@ class RoomDetailFragment @Inject constructor(
private val matrixItemColorProvider: MatrixItemColorProvider,
private val imageContentRenderer: ImageContentRenderer,
private val roomDetailPendingActionStore: RoomDetailPendingActionStore,
private val pillsPostProcessorFactory: PillsPostProcessor.Factory
private val pillsPostProcessorFactory: PillsPostProcessor.Factory,
private val callManager: WebRtcCallManager
) :
VectorBaseFragment<FragmentRoomDetailBinding>(),
TimelineEventController.Callback,
@ -236,7 +237,7 @@ class RoomDetailFragment @Inject constructor(
AttachmentTypeSelectorView.Callback,
AttachmentsHelper.Callback,
GalleryOrCameraDialogHelper.Listener,
ActiveCallView.Callback {
CurrentCallsView.Callback {
companion object {
/**
@ -282,7 +283,7 @@ class RoomDetailFragment @Inject constructor(
override fun getMenuRes() = R.menu.menu_timeline
private lateinit var sharedActionViewModel: MessageSharedActionViewModel
private lateinit var sharedCallActionViewModel: SharedActiveCallViewModel
private lateinit var knownCallsViewModel: SharedKnownCallsViewModel
private lateinit var layoutManager: LinearLayoutManager
private lateinit var jumpToBottomViewVisibilityManager: JumpToBottomViewVisibilityManager
@ -294,12 +295,12 @@ class RoomDetailFragment @Inject constructor(
private lateinit var attachmentTypeSelector: AttachmentTypeSelectorView
private var lockSendButton = false
private val activeCallViewHolder = ActiveCallViewHolder()
private val activeCallViewHolder = KnownCallsViewHolder()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
sharedActionViewModel = activityViewModelProvider.get(MessageSharedActionViewModel::class.java)
sharedCallActionViewModel = activityViewModelProvider.get(SharedActiveCallViewModel::class.java)
knownCallsViewModel = activityViewModelProvider.get(SharedKnownCallsViewModel::class.java)
attachmentsHelper = AttachmentsHelper(requireContext(), this).register()
keyboardStateUtils = KeyboardStateUtils(requireActivity())
setupToolbar(views.roomToolbar)
@ -324,10 +325,10 @@ class RoomDetailFragment @Inject constructor(
}
.disposeOnDestroyView()
sharedCallActionViewModel
.activeCall
knownCallsViewModel
.liveKnownCalls
.observe(viewLifecycleOwner, {
activeCallViewHolder.updateCall(it)
activeCallViewHolder.updateCall(callManager.getCurrentCall(), it)
invalidateOptionsMenu()
})
@ -799,18 +800,15 @@ class RoomDetailFragment @Inject constructor(
showDialogWithMessage(getString(R.string.cannot_call_yourself))
}
}
2 -> {
val activeCall = sharedCallActionViewModel.activeCall.value
if (activeCall != null) {
2 -> {
val currentCall = callManager.getCurrentCall()
if (currentCall != null) {
// resume existing if same room, if not prompt to kill and then restart new call?
if (activeCall.roomId == roomDetailArgs.roomId) {
if (currentCall.mxCall.roomId == roomDetailArgs.roomId) {
onTapToReturnToCall()
} else {
safeStartCall(isVideoCall)
}
// else {
// TODO might not work well, and should prompt
// webRtcPeerConnectionManager.endCall()
// safeStartCall(it, isVideoCall)
// }
} else if (!state.isAllowedToStartWebRTCCall) {
showDialogWithMessage(getString(
if (state.isDm()) {
@ -2015,7 +2013,7 @@ class RoomDetailFragment @Inject constructor(
}
override fun onTapToReturnToCall() {
sharedCallActionViewModel.activeCall.value?.let { call ->
callManager.getCurrentCall()?.let { call ->
VectorCallActivity.newIntent(
context = requireContext(),
callId = call.callId,

View file

@ -38,7 +38,7 @@ import im.vector.app.features.command.ParsedCommand
import im.vector.app.features.crypto.verification.SupportedVerificationMethodsProvider
import im.vector.app.features.home.room.detail.composer.rainbow.RainbowGenerator
import im.vector.app.features.home.room.detail.sticker.StickerPickerActionHandler
import im.vector.app.features.home.room.detail.timeline.helper.RoomSummaryHolder
import im.vector.app.features.home.room.detail.timeline.helper.RoomSummariesHolder
import im.vector.app.features.home.room.detail.timeline.helper.TimelineSettingsFactory
import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever
import im.vector.app.features.home.room.typing.TypingHelper
@ -111,7 +111,7 @@ class RoomDetailViewModel @AssistedInject constructor(
private val rawService: RawService,
private val supportedVerificationMethodsProvider: SupportedVerificationMethodsProvider,
private val stickerPickerActionHandler: StickerPickerActionHandler,
private val roomSummaryHolder: RoomSummaryHolder,
private val roomSummariesHolder: RoomSummariesHolder,
private val typingHelper: TypingHelper,
private val callManager: WebRtcCallManager,
private val chatEffectManager: ChatEffectManager,
@ -349,7 +349,7 @@ class RoomDetailViewModel @AssistedInject constructor(
}
private fun handleEndCall() {
callManager.endCall()
callManager.endCallForRoom(initialState.roomId)
}
private fun handleSelectStickerAttachment() {
@ -1323,6 +1323,7 @@ class RoomDetailViewModel @AssistedInject constructor(
}
}
.subscribe {
Timber.v("Unread state: $it")
setState { copy(unreadState = it) }
}
.disposeOnClear()
@ -1375,7 +1376,7 @@ class RoomDetailViewModel @AssistedInject constructor(
private fun observeSummaryState() {
asyncSubscribe(RoomDetailViewState::asyncRoomSummary) { summary ->
roomSummaryHolder.set(summary)
roomSummariesHolder.set(summary)
setState {
val typingMessage = typingHelper.getTypingMessage(summary.typingUsers)
copy(typingMessage = typingMessage)
@ -1420,7 +1421,7 @@ class RoomDetailViewModel @AssistedInject constructor(
}
override fun onCleared() {
roomSummaryHolder.clear()
roomSummariesHolder.remove(room.roomId)
timeline.dispose()
timeline.removeAllListeners()
if (vectorPreferences.sendTypingNotifs()) {

View file

@ -35,7 +35,6 @@ import im.vector.app.features.home.room.detail.RoomDetailAction
import im.vector.app.features.home.room.detail.RoomDetailViewState
import im.vector.app.features.home.room.detail.UnreadState
import im.vector.app.features.home.room.detail.timeline.factory.MergedHeaderItemFactory
import im.vector.app.features.home.room.detail.timeline.factory.NoticeItemFactory
import im.vector.app.features.home.room.detail.timeline.factory.TimelineItemFactory
import im.vector.app.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder
import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder
@ -77,7 +76,6 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
private val mergedHeaderItemFactory: MergedHeaderItemFactory,
private val session: Session,
private val callManager: WebRtcCallManager,
private val noticeItemFactory: NoticeItemFactory,
@TimelineEventControllerHandler
private val backgroundHandler: Handler
) : EpoxyController(backgroundHandler, backgroundHandler), Timeline.Listener, EpoxyController.Interceptor {
@ -104,6 +102,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
// TODO move all callbacks to this?
fun onTimelineItemAction(itemAction: RoomDetailAction)
// Introduce ViewModel scoped component (or Hilt?)
fun getPreviewUrlRetriever(): PreviewUrlRetriever
}

View file

@ -196,7 +196,7 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
EventType.CALL_CANDIDATES,
EventType.CALL_HANGUP,
EventType.CALL_ANSWER -> {
noticeEventFormatter.format(timelineEvent, room?.roomSummary())
noticeEventFormatter.format(timelineEvent)
}
else -> null
} ?: ""

View file

@ -22,7 +22,7 @@ import im.vector.app.features.home.room.detail.timeline.TimelineEventController
import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider
import im.vector.app.features.home.room.detail.timeline.helper.MessageInformationDataFactory
import im.vector.app.features.home.room.detail.timeline.helper.MessageItemAttributesFactory
import im.vector.app.features.home.room.detail.timeline.helper.RoomSummaryHolder
import im.vector.app.features.home.room.detail.timeline.helper.RoomSummariesHolder
import im.vector.app.features.home.room.detail.timeline.item.CallTileTimelineItem
import im.vector.app.features.home.room.detail.timeline.item.CallTileTimelineItem_
import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData
@ -43,7 +43,7 @@ class CallItemFactory @Inject constructor(
private val messageInformationDataFactory: MessageInformationDataFactory,
private val messageItemAttributesFactory: MessageItemAttributesFactory,
private val avatarSizeProvider: AvatarSizeProvider,
private val roomSummaryHolder: RoomSummaryHolder,
private val roomSummariesHolder: RoomSummariesHolder,
private val callManager: WebRtcCallManager
) {
@ -52,6 +52,7 @@ class CallItemFactory @Inject constructor(
callback: TimelineEventController.Callback?
): VectorEpoxyModel<*>? {
if (event.root.eventId == null) return null
val roomId = event.roomId
val informationData = messageInformationDataFactory.create(event, null)
val callSignalingContent = event.getCallSignallingContent() ?: return null
val callId = callSignalingContent.callId ?: return null
@ -64,6 +65,7 @@ class CallItemFactory @Inject constructor(
return when (event.root.getClearType()) {
EventType.CALL_ANSWER -> {
createCallTileTimelineItem(
roomId = roomId,
callId = callId,
callStatus = CallTileTimelineItem.CallStatus.IN_CALL,
callKind = callKind,
@ -75,6 +77,7 @@ class CallItemFactory @Inject constructor(
}
EventType.CALL_INVITE -> {
createCallTileTimelineItem(
roomId = roomId,
callId = callId,
callStatus = CallTileTimelineItem.CallStatus.INVITED,
callKind = callKind,
@ -86,6 +89,7 @@ class CallItemFactory @Inject constructor(
}
EventType.CALL_REJECT -> {
createCallTileTimelineItem(
roomId = roomId,
callId = callId,
callStatus = CallTileTimelineItem.CallStatus.REJECTED,
callKind = callKind,
@ -97,6 +101,7 @@ class CallItemFactory @Inject constructor(
}
EventType.CALL_HANGUP -> {
createCallTileTimelineItem(
roomId = roomId,
callId = callId,
callStatus = CallTileTimelineItem.CallStatus.ENDED,
callKind = callKind,
@ -121,6 +126,7 @@ class CallItemFactory @Inject constructor(
}
private fun createCallTileTimelineItem(
roomId: String,
callId: String,
callKind: CallTileTimelineItem.CallKind,
callStatus: CallTileTimelineItem.CallStatus,
@ -129,7 +135,7 @@ class CallItemFactory @Inject constructor(
isStillActive: Boolean,
callback: TimelineEventController.Callback?
): CallTileTimelineItem? {
val userOfInterest = roomSummaryHolder.roomSummary?.toMatrixItem() ?: return null
val userOfInterest = roomSummariesHolder.get(roomId)?.toMatrixItem() ?: return null
val attributes = messageItemAttributesFactory.create(null, informationData, callback).let {
CallTileTimelineItem.Attributes(
callId = callId,

View file

@ -22,7 +22,7 @@ import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.room.detail.timeline.TimelineEventController
import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider
import im.vector.app.features.home.room.detail.timeline.helper.MergedTimelineEventVisibilityStateChangedListener
import im.vector.app.features.home.room.detail.timeline.helper.RoomSummaryHolder
import im.vector.app.features.home.room.detail.timeline.helper.RoomSummariesHolder
import im.vector.app.features.home.room.detail.timeline.helper.canBeMerged
import im.vector.app.features.home.room.detail.timeline.helper.isRoomConfiguration
import im.vector.app.features.home.room.detail.timeline.helper.prevSameTypeEvents
@ -47,7 +47,7 @@ import javax.inject.Inject
class MergedHeaderItemFactory @Inject constructor(private val activeSessionHolder: ActiveSessionHolder,
private val avatarRenderer: AvatarRenderer,
private val avatarSizeProvider: AvatarSizeProvider,
private val roomSummaryHolder: RoomSummaryHolder) {
private val roomSummariesHolder: RoomSummariesHolder) {
private val collapsedEventIds = linkedSetOf<Long>()
private val mergeItemCollapseStates = HashMap<Long, Boolean>()
@ -77,7 +77,7 @@ class MergedHeaderItemFactory @Inject constructor(private val activeSessionHolde
}
}
private fun isDirectRoom() = roomSummaryHolder.roomSummary?.isDirect.orFalse()
private fun isDirectRoom(roomId: String) = roomSummariesHolder.get(roomId)?.isDirect.orFalse()
private fun buildMembershipEventsMergedSummary(currentPosition: Int,
items: List<TimelineEvent>,
@ -102,7 +102,7 @@ class MergedHeaderItemFactory @Inject constructor(private val activeSessionHolde
memberName = mergedEvent.senderInfo.disambiguatedDisplayName,
localId = mergedEvent.localId,
eventId = mergedEvent.root.eventId ?: "",
isDirectRoom = isDirectRoom()
isDirectRoom = isDirectRoom(event.roomId)
)
mergedData.add(data)
}
@ -174,7 +174,7 @@ class MergedHeaderItemFactory @Inject constructor(private val activeSessionHolde
memberName = mergedEvent.senderInfo.disambiguatedDisplayName,
localId = mergedEvent.localId,
eventId = mergedEvent.root.eventId ?: "",
isDirectRoom = isDirectRoom()
isDirectRoom = isDirectRoom(event.roomId)
)
mergedData.add(data)
}
@ -191,8 +191,7 @@ class MergedHeaderItemFactory @Inject constructor(private val activeSessionHolde
collapsedEventIds.removeAll(mergedEventIds)
}
val mergeId = mergedEventIds.joinToString(separator = "_") { it.toString() }
val powerLevelsHelper = roomSummaryHolder.roomSummary?.roomId
?.let { activeSessionHolder.getSafeActiveSession()?.getRoom(it) }
val powerLevelsHelper = activeSessionHolder.getSafeActiveSession()?.getRoom(event.roomId)
?.let { it.getStateEvent(EventType.STATE_ROOM_POWER_LEVELS, QueryStringValue.NoCondition)?.content?.toModel<PowerLevelsContent>() }
?.let { PowerLevelsHelper(it) }
val currentUserId = activeSessionHolder.getSafeActiveSession()?.myUserId ?: ""
@ -209,7 +208,7 @@ class MergedHeaderItemFactory @Inject constructor(private val activeSessionHolde
readReceiptsCallback = callback,
callback = callback,
currentUserId = currentUserId,
roomSummary = roomSummaryHolder.roomSummary,
roomSummary = roomSummariesHolder.get(event.roomId),
canChangeAvatar = powerLevelsHelper?.isUserAllowedToSend(currentUserId, true, EventType.STATE_ROOM_AVATAR) ?: false,
canChangeTopic = powerLevelsHelper?.isUserAllowedToSend(currentUserId, true, EventType.STATE_ROOM_TOPIC) ?: false,
canChangeName = powerLevelsHelper?.isUserAllowedToSend(currentUserId, true, EventType.STATE_ROOM_NAME) ?: false

View file

@ -38,7 +38,6 @@ import im.vector.app.features.home.room.detail.timeline.helper.ContentDownloadSt
import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder
import im.vector.app.features.home.room.detail.timeline.helper.MessageInformationDataFactory
import im.vector.app.features.home.room.detail.timeline.helper.MessageItemAttributesFactory
import im.vector.app.features.home.room.detail.timeline.helper.RoomSummaryHolder
import im.vector.app.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem
import im.vector.app.features.home.room.detail.timeline.item.MessageBlockCodeItem
@ -105,15 +104,17 @@ class MessageItemFactory @Inject constructor(
private val messageItemAttributesFactory: MessageItemAttributesFactory,
private val contentUploadStateTrackerBinder: ContentUploadStateTrackerBinder,
private val contentDownloadStateTrackerBinder: ContentDownloadStateTrackerBinder,
private val roomSummaryHolder: RoomSummaryHolder,
private val defaultItemFactory: DefaultItemFactory,
private val noticeItemFactory: NoticeItemFactory,
private val avatarSizeProvider: AvatarSizeProvider,
private val pillsPostProcessorFactory: PillsPostProcessor.Factory,
private val session: Session) {
// TODO inject this properly?
private var roomId: String = ""
private val pillsPostProcessor by lazy {
pillsPostProcessorFactory.create(roomSummaryHolder.roomSummary?.roomId)
pillsPostProcessorFactory.create(roomId)
}
fun create(event: TimelineEvent,
@ -122,8 +123,8 @@ class MessageItemFactory @Inject constructor(
callback: TimelineEventController.Callback?
): VectorEpoxyModel<*>? {
event.root.eventId ?: return null
roomId = event.roomId
val informationData = messageInformationDataFactory.create(event, nextEvent)
if (event.root.isRedacted()) {
// message is redacted
val attributes = messageItemAttributesFactory.create(null, informationData, callback)
@ -139,7 +140,7 @@ class MessageItemFactory @Inject constructor(
|| event.isEncrypted() && event.root.content.toModel<EncryptedEventContent>()?.relatesTo?.type == RelationType.REPLACE
) {
// This is an edit event, we should display it when debugging as a notice event
return noticeItemFactory.create(event, highlight, roomSummaryHolder.roomSummary, callback)
return noticeItemFactory.create(event, highlight, callback)
}
val attributes = messageItemAttributesFactory.create(messageContent, informationData, callback)
@ -155,7 +156,7 @@ class MessageItemFactory @Inject constructor(
is MessageAudioContent -> buildAudioMessageItem(messageContent, informationData, highlight, attributes)
is MessageVerificationRequestContent -> buildVerificationRequestMessageItem(messageContent, informationData, highlight, callback, attributes)
is MessageOptionsContent -> buildOptionsMessageItem(messageContent, informationData, highlight, callback, attributes)
is MessagePollResponseContent -> noticeItemFactory.create(event, highlight, roomSummaryHolder.roomSummary, callback)
is MessagePollResponseContent -> noticeItemFactory.create(event, highlight, callback)
else -> buildNotHandledMessageItem(messageContent, informationData, highlight, callback, attributes)
}
}
@ -229,14 +230,13 @@ class MessageItemFactory @Inject constructor(
attributes: AbsMessageItem.Attributes): VerificationRequestItem? {
// If this request is not sent by me or sent to me, we should ignore it in timeline
val myUserId = session.myUserId
val roomId = roomSummaryHolder.roomSummary?.roomId
if (informationData.senderId != myUserId && messageContent.toUserId != myUserId) {
return null
}
val otherUserId = if (informationData.sentByMe) messageContent.toUserId else informationData.senderId
val otherUserName = if (informationData.sentByMe) {
session.getRoomMember(messageContent.toUserId, roomId ?: "")?.displayName
session.getRoomMember(messageContent.toUserId, roomId)?.displayName
} else {
informationData.memberName
}

View file

@ -24,7 +24,6 @@ import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvide
import im.vector.app.features.home.room.detail.timeline.helper.MessageInformationDataFactory
import im.vector.app.features.home.room.detail.timeline.item.NoticeItem
import im.vector.app.features.home.room.detail.timeline.item.NoticeItem_
import org.matrix.android.sdk.api.session.room.model.RoomSummary
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import javax.inject.Inject
@ -35,9 +34,8 @@ class NoticeItemFactory @Inject constructor(private val eventFormatter: NoticeEv
fun create(event: TimelineEvent,
highlight: Boolean,
roomSummary: RoomSummary?,
callback: TimelineEventController.Callback?): NoticeItem? {
val formattedText = eventFormatter.format(event, roomSummary) ?: return null
val formattedText = eventFormatter.format(event) ?: return null
val informationData = informationDataFactory.create(event, null)
val attributes = NoticeItem.Attributes(
avatarRenderer = avatarRenderer,

View file

@ -21,7 +21,6 @@ import im.vector.app.core.epoxy.VectorEpoxyModel
import im.vector.app.core.resources.StringProvider
import im.vector.app.core.resources.UserPreferencesProvider
import im.vector.app.features.home.room.detail.timeline.TimelineEventController
import im.vector.app.features.home.room.detail.timeline.helper.RoomSummaryHolder
import im.vector.app.features.home.room.detail.timeline.item.RoomCreateItem_
import me.gujun.android.span.span
import org.matrix.android.sdk.api.session.Session
@ -33,7 +32,6 @@ import javax.inject.Inject
class RoomCreateItemFactory @Inject constructor(private val stringProvider: StringProvider,
private val userPreferencesProvider: UserPreferencesProvider,
private val session: Session,
private val roomSummaryHolder: RoomSummaryHolder,
private val noticeItemFactory: NoticeItemFactory) {
fun create(event: TimelineEvent, callback: TimelineEventController.Callback?): VectorEpoxyModel<*>? {
@ -54,7 +52,7 @@ class RoomCreateItemFactory @Inject constructor(private val stringProvider: Stri
private fun defaultRendering(event: TimelineEvent, callback: TimelineEventController.Callback?): VectorEpoxyModel<*>? {
return if (userPreferencesProvider.shouldShowHiddenEvents()) {
noticeItemFactory.create(event, false, roomSummaryHolder.roomSummary, callback)
noticeItemFactory.create(event, false, callback)
} else {
null
}

View file

@ -20,7 +20,7 @@ import im.vector.app.core.epoxy.EmptyItem_
import im.vector.app.core.epoxy.VectorEpoxyModel
import im.vector.app.core.resources.UserPreferencesProvider
import im.vector.app.features.home.room.detail.timeline.TimelineEventController
import im.vector.app.features.home.room.detail.timeline.helper.RoomSummaryHolder
import im.vector.app.features.home.room.detail.timeline.helper.RoomSummariesHolder
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import timber.log.Timber
@ -32,7 +32,7 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me
private val defaultItemFactory: DefaultItemFactory,
private val encryptionItemFactory: EncryptionItemFactory,
private val roomCreateItemFactory: RoomCreateItemFactory,
private val roomSummaryHolder: RoomSummaryHolder,
private val roomSummariesHolder: RoomSummariesHolder,
private val verificationConclusionItemFactory: VerificationItemFactory,
private val callItemFactory: CallItemFactory,
private val userPreferencesProvider: UserPreferencesProvider) {
@ -63,7 +63,7 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me
EventType.STATE_ROOM_WIDGET,
EventType.STATE_ROOM_POWER_LEVELS,
EventType.REACTION,
EventType.REDACTION -> noticeItemFactory.create(event, highlight, roomSummaryHolder.roomSummary, callback)
EventType.REDACTION -> noticeItemFactory.create(event, highlight, callback)
EventType.STATE_ROOM_ENCRYPTION -> encryptionItemFactory.create(event, highlight, callback)
// State room create
EventType.STATE_ROOM_CREATE -> roomCreateItemFactory.create(event, callback)
@ -91,7 +91,7 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me
// TODO These are not filtered out by timeline when encrypted
// For now manually ignore
if (userPreferencesProvider.shouldShowHiddenEvents()) {
noticeItemFactory.create(event, highlight, roomSummaryHolder.roomSummary, callback)
noticeItemFactory.create(event, highlight, callback)
} else {
null
}

View file

@ -24,7 +24,6 @@ import im.vector.app.features.home.room.detail.timeline.TimelineEventController
import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider
import im.vector.app.features.home.room.detail.timeline.helper.MessageInformationDataFactory
import im.vector.app.features.home.room.detail.timeline.helper.MessageItemAttributesFactory
import im.vector.app.features.home.room.detail.timeline.helper.RoomSummaryHolder
import im.vector.app.features.home.room.detail.timeline.item.StatusTileTimelineItem
import im.vector.app.features.home.room.detail.timeline.item.StatusTileTimelineItem_
import org.matrix.android.sdk.api.session.Session
@ -51,7 +50,6 @@ class VerificationItemFactory @Inject constructor(
private val avatarSizeProvider: AvatarSizeProvider,
private val noticeItemFactory: NoticeItemFactory,
private val userPreferencesProvider: UserPreferencesProvider,
private val roomSummaryHolder: RoomSummaryHolder,
private val stringProvider: StringProvider,
private val session: Session
) {
@ -153,7 +151,7 @@ class VerificationItemFactory @Inject constructor(
highlight: Boolean,
callback: TimelineEventController.Callback?
): VectorEpoxyModel<*>? {
if (userPreferencesProvider.shouldShowHiddenEvents()) return noticeItemFactory.create(event, highlight, roomSummaryHolder.roomSummary, callback)
if (userPreferencesProvider.shouldShowHiddenEvents()) return noticeItemFactory.create(event, highlight, callback)
return null
}
}

View file

@ -23,7 +23,6 @@ import im.vector.app.core.resources.StringProvider
import me.gujun.android.span.span
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.RoomSummary
import org.matrix.android.sdk.api.session.room.model.message.MessageOptionsContent
import org.matrix.android.sdk.api.session.room.model.message.MessageType
import org.matrix.android.sdk.api.session.room.model.message.OPTION_TYPE_BUTTONS
@ -41,7 +40,7 @@ class DisplayableEventFormatter @Inject constructor(
private val noticeEventFormatter: NoticeEventFormatter
) {
fun format(timelineEvent: TimelineEvent, appendAuthor: Boolean, roomSummary: RoomSummary?): CharSequence {
fun format(timelineEvent: TimelineEvent, appendAuthor: Boolean): CharSequence {
if (timelineEvent.root.isRedacted()) {
return noticeEventFormatter.formatRedactedEvent(timelineEvent.root)
}
@ -131,7 +130,7 @@ class DisplayableEventFormatter @Inject constructor(
}
else -> {
return span {
text = noticeEventFormatter.format(timelineEvent, roomSummary) ?: ""
text = noticeEventFormatter.format(timelineEvent) ?: ""
textStyle = "italic"
}
}

View file

@ -19,6 +19,7 @@ package im.vector.app.features.home.room.detail.timeline.format
import im.vector.app.ActiveSessionDataSource
import im.vector.app.R
import im.vector.app.core.resources.StringProvider
import im.vector.app.features.home.room.detail.timeline.helper.RoomSummariesHolder
import im.vector.app.features.settings.VectorPreferences
import org.matrix.android.sdk.api.extensions.appendNl
import org.matrix.android.sdk.api.extensions.orFalse
@ -55,6 +56,7 @@ class NoticeEventFormatter @Inject constructor(
private val activeSessionDataSource: ActiveSessionDataSource,
private val roomHistoryVisibilityFormatter: RoomHistoryVisibilityFormatter,
private val vectorPreferences: VectorPreferences,
private val roomSummariesHolder: RoomSummariesHolder,
private val sp: StringProvider
) {
@ -65,7 +67,8 @@ class NoticeEventFormatter @Inject constructor(
private fun RoomSummary?.isDm() = this?.isDirect.orFalse()
fun format(timelineEvent: TimelineEvent, rs: RoomSummary?): CharSequence? {
fun format(timelineEvent: TimelineEvent): CharSequence? {
val rs = roomSummariesHolder.get(timelineEvent.roomId)
return when (val type = timelineEvent.root.getClearType()) {
EventType.STATE_ROOM_JOIN_RULES -> formatJoinRulesEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName, rs)
EventType.STATE_ROOM_CREATE -> formatRoomCreateEvent(timelineEvent.root, rs)

View file

@ -45,7 +45,7 @@ import javax.inject.Inject
* This class compute if data of an event (such has avatar, display name, ...) should be displayed, depending on the previous event in the timeline
*/
class MessageInformationDataFactory @Inject constructor(private val session: Session,
private val roomSummaryHolder: RoomSummaryHolder,
private val roomSummariesHolder: RoomSummariesHolder,
private val dateFormatter: VectorDateFormatter,
private val vectorPreferences: VectorPreferences) {
@ -116,7 +116,7 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses
}
private fun getE2EDecoration(event: TimelineEvent): E2EDecoration {
val roomSummary = roomSummaryHolder.roomSummary
val roomSummary = roomSummariesHolder.get(event.roomId)
return if (
event.root.sendState == SendState.SYNCED
&& roomSummary?.isEncrypted.orFalse()

View file

@ -16,25 +16,28 @@
package im.vector.app.features.home.room.detail.timeline.helper
import im.vector.app.core.di.ScreenScope
import org.matrix.android.sdk.api.session.room.model.RoomSummary
import javax.inject.Inject
import javax.inject.Singleton
/*
This holds an instance of the current room summary.
You should use this in the context of the timeline.
You can use this to share room summary instances within the app.
You should probably use this only in the context of the timeline
*/
@ScreenScope
class RoomSummaryHolder @Inject constructor() {
@Singleton
class RoomSummariesHolder @Inject constructor() {
var roomSummary: RoomSummary? = null
private set
private var roomSummaries = HashMap<String, RoomSummary>()
fun set(roomSummary: RoomSummary) {
this.roomSummary = roomSummary
roomSummaries[roomSummary.roomId] = roomSummary
}
fun get(roomId: String) = roomSummaries[roomId]
fun remove(roomId: String) = roomSummaries.remove(roomId)
fun clear() {
roomSummary = null
roomSummaries.clear()
}
}

View file

@ -86,7 +86,7 @@ class RoomSummaryItemFactory @Inject constructor(private val displayableEventFor
var latestEventTime: CharSequence = ""
val latestEvent = roomSummary.latestPreviewableEvent
if (latestEvent != null) {
latestFormattedEvent = displayableEventFormatter.format(latestEvent, roomSummary.isDirect.not(), roomSummary)
latestFormattedEvent = displayableEventFormatter.format(latestEvent, roomSummary.isDirect.not())
latestEventTime = dateFormatter.format(latestEvent.root.originServerTs, DateFormatKind.ROOM_LIST)
}
val typingMessage = typingHelper.getTypingMessage(roomSummary.typingUsers)

View file

@ -17,8 +17,8 @@
package im.vector.app.features.invite
import im.vector.app.core.platform.VectorViewModelAction
import im.vector.app.features.userdirectory.PendingInvitee
import im.vector.app.features.userdirectory.PendingSelection
sealed class InviteUsersToRoomAction : VectorViewModelAction {
data class InviteSelectedUsers(val invitees: Set<PendingInvitee>) : InviteUsersToRoomAction()
data class InviteSelectedUsers(val selections: Set<PendingSelection>) : InviteUsersToRoomAction()
}

View file

@ -39,6 +39,7 @@ import im.vector.app.core.utils.checkPermissions
import im.vector.app.core.utils.toast
import im.vector.app.features.contactsbook.ContactsBookFragment
import im.vector.app.features.contactsbook.ContactsBookViewModel
import im.vector.app.features.contactsbook.ContactsBookViewState
import im.vector.app.features.userdirectory.UserListFragment
import im.vector.app.features.userdirectory.UserListFragmentArgs
import im.vector.app.features.userdirectory.UserListSharedAction
@ -53,7 +54,7 @@ import javax.inject.Inject
@Parcelize
data class InviteUsersToRoomArgs(val roomId: String) : Parcelable
class InviteUsersToRoomActivity : SimpleFragmentActivity(), UserListViewModel.Factory {
class InviteUsersToRoomActivity : SimpleFragmentActivity(), UserListViewModel.Factory, ContactsBookViewModel.Factory, InviteUsersToRoomViewModel.Factory {
private val viewModel: InviteUsersToRoomViewModel by viewModel()
private lateinit var sharedActionViewModel: UserListSharedActionViewModel
@ -67,9 +68,11 @@ class InviteUsersToRoomActivity : SimpleFragmentActivity(), UserListViewModel.Fa
injector.inject(this)
}
override fun create(initialState: UserListViewState): UserListViewModel {
return userListViewModelFactory.create(initialState)
}
override fun create(initialState: UserListViewState) = userListViewModelFactory.create(initialState)
override fun create(initialState: ContactsBookViewState) = contactsBookViewModelFactory.create(initialState)
override fun create(initialState: InviteUsersToRoomViewState) = inviteUsersToRoomViewModelFactory.create(initialState)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -92,7 +95,6 @@ class InviteUsersToRoomActivity : SimpleFragmentActivity(), UserListViewModel.Fa
}
.disposeOnDestroy()
if (isFirstCreation()) {
val args: InviteUsersToRoomArgs? = intent.extras?.getParcelable(MvRx.KEY_ARG)
addFragment(
R.id.container,
UserListFragment::class.java,
@ -100,7 +102,7 @@ class InviteUsersToRoomActivity : SimpleFragmentActivity(), UserListViewModel.Fa
title = getString(R.string.invite_users_to_room_title),
menuResId = R.menu.vector_invite_users_to_room,
excludedUserIds = viewModel.getUserIdsOfRoomMembers(),
existingRoomId = args?.roomId
showInviteActions = false
)
)
}
@ -110,7 +112,7 @@ class InviteUsersToRoomActivity : SimpleFragmentActivity(), UserListViewModel.Fa
private fun onMenuItemSelected(action: UserListSharedAction.OnMenuItemSelected) {
if (action.itemId == R.id.action_invite_users_to_room_invite) {
viewModel.handle(InviteUsersToRoomAction.InviteSelectedUsers(action.invitees))
viewModel.handle(InviteUsersToRoomAction.InviteSelectedUsers(action.selections))
}
}

View file

@ -17,6 +17,7 @@
package im.vector.app.features.invite
import com.airbnb.mvrx.ActivityViewModelContext
import com.airbnb.mvrx.FragmentViewModelContext
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import com.squareup.inject.assisted.Assisted
@ -24,7 +25,7 @@ import com.squareup.inject.assisted.AssistedInject
import im.vector.app.R
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.core.resources.StringProvider
import im.vector.app.features.userdirectory.PendingInvitee
import im.vector.app.features.userdirectory.PendingSelection
import io.reactivex.Observable
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.rx.rx
@ -46,37 +47,40 @@ class InviteUsersToRoomViewModel @AssistedInject constructor(@Assisted
@JvmStatic
override fun create(viewModelContext: ViewModelContext, state: InviteUsersToRoomViewState): InviteUsersToRoomViewModel? {
val activity: InviteUsersToRoomActivity = (viewModelContext as ActivityViewModelContext).activity()
return activity.inviteUsersToRoomViewModelFactory.create(state)
val factory = when (viewModelContext) {
is FragmentViewModelContext -> viewModelContext.fragment as? Factory
is ActivityViewModelContext -> viewModelContext.activity as? Factory
}
return factory?.create(state) ?: error("You should let your activity/fragment implements Factory interface")
}
}
override fun handle(action: InviteUsersToRoomAction) {
when (action) {
is InviteUsersToRoomAction.InviteSelectedUsers -> inviteUsersToRoom(action.invitees)
is InviteUsersToRoomAction.InviteSelectedUsers -> inviteUsersToRoom(action.selections)
}
}
private fun inviteUsersToRoom(invitees: Set<PendingInvitee>) {
private fun inviteUsersToRoom(selections: Set<PendingSelection>) {
_viewEvents.post(InviteUsersToRoomViewEvents.Loading)
Observable.fromIterable(invitees).flatMapCompletable { user ->
Observable.fromIterable(selections).flatMapCompletable { user ->
when (user) {
is PendingInvitee.UserPendingInvitee -> room.rx().invite(user.user.userId, null)
is PendingInvitee.ThreePidPendingInvitee -> room.rx().invite3pid(user.threePid)
is PendingSelection.UserPendingSelection -> room.rx().invite(user.user.userId, null)
is PendingSelection.ThreePidPendingSelection -> room.rx().invite3pid(user.threePid)
}
}.subscribe(
{
val successMessage = when (invitees.size) {
val successMessage = when (selections.size) {
1 -> stringProvider.getString(R.string.invitation_sent_to_one_user,
invitees.first().getBestName())
selections.first().getBestName())
2 -> stringProvider.getString(R.string.invitations_sent_to_two_users,
invitees.first().getBestName(),
invitees.last().getBestName())
selections.first().getBestName(),
selections.last().getBestName())
else -> stringProvider.getQuantityString(R.plurals.invitations_sent_to_one_and_more_users,
invitees.size - 1,
invitees.first().getBestName(),
invitees.size - 1)
selections.size - 1,
selections.first().getBestName(),
selections.size - 1)
}
_viewEvents.post(InviteUsersToRoomViewEvents.Success(successMessage))
},

View file

@ -34,6 +34,7 @@ import im.vector.app.core.platform.VectorBaseActivity
import im.vector.app.core.utils.toast
import im.vector.app.features.call.conference.JitsiCallViewModel
import im.vector.app.features.call.conference.VectorJitsiActivity
import im.vector.app.features.call.transfer.CallTransferActivity
import im.vector.app.features.createdirect.CreateDirectRoomActivity
import im.vector.app.features.crypto.keysbackup.settings.KeysBackupManageActivity
import im.vector.app.features.crypto.keysbackup.setup.KeysBackupSetupActivity
@ -344,6 +345,11 @@ class DefaultNavigator @Inject constructor(
context.startActivity(intent)
}
override fun openCallTransfer(context: Context, callId: String) {
val intent = CallTransferActivity.newIntent(context, callId)
context.startActivity(intent)
}
private fun startActivity(context: Context, intent: Intent, buildTask: Boolean) {
if (buildTask) {
val stackBuilder = TaskStackBuilder.create(context)

View file

@ -113,4 +113,6 @@ interface Navigator {
options: ((MutableList<Pair<View, String>>) -> Unit)?)
fun openSearch(context: Context, roomId: String)
fun openCallTransfer(context: Context, callId: String)
}

View file

@ -91,7 +91,7 @@ class NotifiableEventResolver @Inject constructor(private val stringProvider: St
if (room == null) {
Timber.e("## Unable to resolve room for eventId [$event]")
// Ok room is not known in store, but we can still display something
val body = displayableEventFormatter.format(event, false, null)
val body = displayableEventFormatter.format(event, false)
val roomName = stringProvider.getString(R.string.notification_unknown_room_name)
val senderDisplayName = event.senderInfo.disambiguatedDisplayName
@ -124,7 +124,7 @@ class NotifiableEventResolver @Inject constructor(private val stringProvider: St
}
}
val body = displayableEventFormatter.format(event, false, room.roomSummary()).toString()
val body = displayableEventFormatter.format(event, false).toString()
val roomName = room.roomSummary()?.displayName ?: ""
val senderDisplayName = event.senderInfo.disambiguatedDisplayName

View file

@ -297,12 +297,6 @@ class NotificationUtils @Inject constructor(private val context: Context,
.setLights(accentColor, 500, 500)
.setOngoing(true)
// Compat: Display the incoming call notification on the lock screen
builder.priority = NotificationCompat.PRIORITY_HIGH
//
// val pendingIntent = stackBuilder.getPendingIntent(requestId, PendingIntent.FLAG_UPDATE_CURRENT)
val contentIntent = VectorCallActivity.newIntent(
context = context,
mxCall = mxCall,
@ -340,9 +334,11 @@ class NotificationUtils @Inject constructor(private val context: Context,
answerCallPendingIntent
)
)
builder.setFullScreenIntent(contentPendingIntent, true)
if (fromBg) {
// Compat: Display the incoming call notification on the lock screen
builder.priority = NotificationCompat.PRIORITY_HIGH
builder.setFullScreenIntent(contentPendingIntent, true)
}
return builder.build()
}
@ -393,9 +389,8 @@ class NotificationUtils @Inject constructor(private val context: Context,
*/
@SuppressLint("NewApi")
fun buildPendingCallNotification(mxCall: MxCall,
title: String,
fromBg: Boolean = false): Notification {
val builder = NotificationCompat.Builder(context, if (fromBg) CALL_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID)
title: String): Notification {
val builder = NotificationCompat.Builder(context, SILENT_NOTIFICATION_CHANNEL_ID)
.setContentTitle(ensureTitleNotEmpty(title))
.apply {
if (mxCall.isVideoCall) {
@ -407,11 +402,6 @@ class NotificationUtils @Inject constructor(private val context: Context,
.setSmallIcon(R.drawable.incoming_call_notification_transparent)
.setCategory(NotificationCompat.CATEGORY_CALL)
if (fromBg) {
builder.priority = NotificationCompat.PRIORITY_LOW
builder.setOngoing(true)
}
val rejectCallPendingIntent = buildRejectCallPendingIntent(mxCall.callId)
builder.addAction(
@ -450,6 +440,7 @@ class NotificationUtils @Inject constructor(private val context: Context,
fun buildCallEndedNotification(): Notification {
return NotificationCompat.Builder(context, SILENT_NOTIFICATION_CHANNEL_ID)
.setContentTitle(stringProvider.getString(R.string.call_ended))
.setTimeoutAfter(2000)
.setSmallIcon(R.drawable.ic_material_call_end_grey)
.setCategory(NotificationCompat.CATEGORY_CALL)
.build()

View file

@ -21,6 +21,8 @@ import android.view.View
import android.widget.ImageView
import android.widget.TextView
import im.vector.app.R
import im.vector.app.core.extensions.setLeftDrawable
import im.vector.app.core.glide.GlideApp
import im.vector.app.features.home.AvatarRenderer
import org.matrix.android.sdk.api.util.MatrixItem
@ -42,18 +44,24 @@ class IncomingCallAlert(uid: String,
: VectorAlert.ViewBinder {
override fun bind(view: View) {
val callKind = if (isVideoCall) {
R.string.action_video_call
val (callKindText, callKindIcon) = if (isVideoCall) {
Pair(R.string.action_video_call, R.drawable.ic_call_video_small)
} else {
R.string.action_voice_call
Pair(R.string.action_voice_call, R.drawable.ic_call_audio_small)
}
view.findViewById<TextView>(R.id.incomingCallKindView).apply {
setText(callKindText)
setLeftDrawable(callKindIcon)
}
view.findViewById<TextView>(R.id.incomingCallKindView).setText(callKind)
view.findViewById<TextView>(R.id.incomingCallNameView).text = matrixItem?.getBestName()
view.findViewById<ImageView>(R.id.incomingCallAvatar)?.let { imageView ->
matrixItem?.let { avatarRenderer.render(it, imageView) }
matrixItem?.let { avatarRenderer.render(it, imageView, GlideApp.with(view.context.applicationContext)) }
}
view.findViewById<ImageView>(R.id.incomingCallAcceptView).setOnClickListener {
onAccept()
view.findViewById<ImageView>(R.id.incomingCallAcceptView).apply {
setOnClickListener {
onAccept()
}
setImageResource(callKindIcon)
}
view.findViewById<ImageView>(R.id.incomingCallRejectView).setOnClickListener {
onReject()

View file

@ -21,6 +21,7 @@ import android.view.View
import android.widget.ImageView
import androidx.annotation.DrawableRes
import im.vector.app.R
import im.vector.app.core.glide.GlideApp
import im.vector.app.features.home.AvatarRenderer
import org.matrix.android.sdk.api.util.MatrixItem
@ -43,7 +44,7 @@ class VerificationVectorAlert(uid: String,
override fun bind(view: View) {
view.findViewById<ImageView>(R.id.ivUserAvatar)?.let { imageView ->
matrixItem?.let { avatarRenderer.render(it, imageView) }
matrixItem?.let { avatarRenderer.render(it, imageView, GlideApp.with(view.context.applicationContext)) }
}
}
}

View file

@ -19,14 +19,14 @@ package im.vector.app.features.userdirectory
import org.matrix.android.sdk.api.session.identity.ThreePid
import org.matrix.android.sdk.api.session.user.model.User
sealed class PendingInvitee {
data class UserPendingInvitee(val user: User) : PendingInvitee()
data class ThreePidPendingInvitee(val threePid: ThreePid) : PendingInvitee()
sealed class PendingSelection {
data class UserPendingSelection(val user: User) : PendingSelection()
data class ThreePidPendingSelection(val threePid: ThreePid) : PendingSelection()
fun getBestName(): String {
return when (this) {
is UserPendingInvitee -> user.getBestName()
is ThreePidPendingInvitee -> threePid.value
is UserPendingSelection -> user.getBestName()
is ThreePidPendingSelection -> threePid.value
}
}
}

View file

@ -21,7 +21,7 @@ import im.vector.app.core.platform.VectorViewModelAction
sealed class UserListAction : VectorViewModelAction {
data class SearchUsers(val value: String) : UserListAction()
object ClearSearchUsers : UserListAction()
data class SelectPendingInvitee(val pendingInvitee: PendingInvitee) : UserListAction()
data class RemovePendingInvitee(val pendingInvitee: PendingInvitee) : UserListAction()
data class AddPendingSelection(val pendingSelection: PendingSelection) : UserListAction()
data class RemovePendingSelection(val pendingSelection: PendingSelection) : UserListAction()
object ComputeMatrixToLinkForSharing : UserListAction()
}

View file

@ -54,10 +54,7 @@ class UserListController @Inject constructor(private val session: Session,
// Build generic items
if (currentState.searchTerm.isBlank()) {
// For now we remove this option if in invite to existing room flow (and not create DM)
if (currentState.pendingInvitees.isEmpty()
// For now we remove this option if in invite to existing room flow (and not create DM)
&& currentState.existingRoomId == null) {
if (currentState.showInviteActions()) {
actionItem {
id(R.drawable.ic_share)
title(stringProvider.getString(R.string.invite_friends))
@ -75,9 +72,7 @@ class UserListController @Inject constructor(private val session: Session,
callback?.onContactBookClick()
})
}
if (currentState.pendingInvitees.isEmpty()
// For now we remove this option if in invite to existing room flow (and not create DM)
&& currentState.existingRoomId == null) {
if (currentState.showInviteActions()) {
actionItem {
id(R.drawable.ic_qr_code_add)
title(stringProvider.getString(R.string.qr_code))

View file

@ -67,18 +67,22 @@ class UserListFragment @Inject constructor(
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
sharedActionViewModel = activityViewModelProvider.get(UserListSharedActionViewModel::class.java)
views.userListTitle.text = args.title
vectorBaseActivity.setSupportActionBar(views.userListToolbar)
if (args.showToolbar) {
views.userListTitle.text = args.title
vectorBaseActivity.setSupportActionBar(views.userListToolbar)
setupCloseView()
views.userListToolbar.isVisible = true
} else {
views.userListToolbar.isVisible = false
}
setupRecyclerView()
setupSearchView()
setupCloseView()
homeServerCapabilitiesViewModel.subscribe {
views.userListE2EbyDefaultDisabled.isVisible = !it.isE2EByDefault
}
viewModel.selectSubscribe(this, UserListViewState::pendingInvitees) {
viewModel.selectSubscribe(this, UserListViewState::pendingSelections) {
renderSelectedUsers(it)
}
@ -105,7 +109,7 @@ class UserListFragment @Inject constructor(
override fun onPrepareOptionsMenu(menu: Menu) {
withState(viewModel) {
val showMenuItem = it.pendingInvitees.isNotEmpty()
val showMenuItem = it.pendingSelections.isNotEmpty()
menu.forEach { menuItem ->
menuItem.isVisible = showMenuItem
}
@ -114,7 +118,7 @@ class UserListFragment @Inject constructor(
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = withState(viewModel) {
sharedActionViewModel.post(UserListSharedAction.OnMenuItemSelected(item.itemId, it.pendingInvitees))
sharedActionViewModel.post(UserListSharedAction.OnMenuItemSelected(item.itemId, it.pendingSelections))
return@withState true
}
@ -156,14 +160,14 @@ class UserListFragment @Inject constructor(
userListController.setData(it)
}
private fun renderSelectedUsers(invitees: Set<PendingInvitee>) {
private fun renderSelectedUsers(selections: Set<PendingSelection>) {
invalidateOptionsMenu()
val currentNumberOfChips = views.chipGroup.childCount
val newNumberOfChips = invitees.size
val newNumberOfChips = selections.size
views.chipGroup.removeAllViews()
invitees.forEach { addChipToGroup(it) }
selections.forEach { addChipToGroup(it) }
// Scroll to the bottom when adding chips. When removing chips, do not scroll
if (newNumberOfChips >= currentNumberOfChips) {
@ -173,20 +177,22 @@ class UserListFragment @Inject constructor(
}
}
private fun addChipToGroup(pendingInvitee: PendingInvitee) {
private fun addChipToGroup(pendingSelection: PendingSelection) {
val chip = Chip(requireContext())
chip.setChipBackgroundColorResource(android.R.color.transparent)
chip.chipStrokeWidth = dimensionConverter.dpToPx(1).toFloat()
chip.text = pendingInvitee.getBestName()
chip.text = pendingSelection.getBestName()
chip.isClickable = true
chip.isCheckable = false
chip.isCloseIconVisible = true
views.chipGroup.addView(chip)
chip.setOnCloseIconClickListener {
viewModel.handle(UserListAction.RemovePendingInvitee(pendingInvitee))
viewModel.handle(UserListAction.RemovePendingSelection(pendingSelection))
}
}
fun getCurrentState() = withState(viewModel) { it }
override fun onInviteFriendClick() {
viewModel.handle(UserListAction.ComputeMatrixToLinkForSharing)
}
@ -197,17 +203,17 @@ class UserListFragment @Inject constructor(
override fun onItemClick(user: User) {
view?.hideKeyboard()
viewModel.handle(UserListAction.SelectPendingInvitee(PendingInvitee.UserPendingInvitee(user)))
viewModel.handle(UserListAction.AddPendingSelection(PendingSelection.UserPendingSelection(user)))
}
override fun onMatrixIdClick(matrixId: String) {
view?.hideKeyboard()
viewModel.handle(UserListAction.SelectPendingInvitee(PendingInvitee.UserPendingInvitee(User(matrixId))))
viewModel.handle(UserListAction.AddPendingSelection(PendingSelection.UserPendingSelection(User(matrixId))))
}
override fun onThreePidClick(threePid: ThreePid) {
view?.hideKeyboard()
viewModel.handle(UserListAction.SelectPendingInvitee(PendingInvitee.ThreePidPendingInvitee(threePid)))
viewModel.handle(UserListAction.AddPendingSelection(PendingSelection.ThreePidPendingSelection(threePid)))
}
override fun onUseQRCode() {

View file

@ -24,5 +24,7 @@ data class UserListFragmentArgs(
val title: String,
val menuResId: Int,
val excludedUserIds: Set<String>? = null,
val existingRoomId: String? = null
val singleSelection: Boolean = false,
val showInviteActions: Boolean = true,
val showToolbar: Boolean = true
) : Parcelable

View file

@ -21,7 +21,7 @@ import im.vector.app.core.platform.VectorSharedAction
sealed class UserListSharedAction : VectorSharedAction {
object Close : UserListSharedAction()
object GoBack : UserListSharedAction()
data class OnMenuItemSelected(val itemId: Int, val invitees: Set<PendingInvitee>) : UserListSharedAction()
data class OnMenuItemSelected(val itemId: Int, val selections: Set<PendingSelection>) : UserListSharedAction()
object OpenPhoneBook : UserListSharedAction()
object AddByQrCode : UserListSharedAction()
}

View file

@ -68,21 +68,15 @@ class UserListViewModel @AssistedInject constructor(@Assisted initialState: User
}
init {
setState {
copy(
myUserId = session.myUserId,
existingRoomId = initialState.existingRoomId
)
}
observeUsers()
}
override fun handle(action: UserListAction) {
when (action) {
is UserListAction.SearchUsers -> handleSearchUsers(action.value)
is UserListAction.ClearSearchUsers -> handleClearSearchUsers()
is UserListAction.SelectPendingInvitee -> handleSelectUser(action)
is UserListAction.RemovePendingInvitee -> handleRemoveSelectedUser(action)
is UserListAction.SearchUsers -> handleSearchUsers(action.value)
is UserListAction.ClearSearchUsers -> handleClearSearchUsers()
is UserListAction.AddPendingSelection -> handleSelectUser(action)
is UserListAction.RemovePendingSelection -> handleRemoveSelectedUser(action)
UserListAction.ComputeMatrixToLinkForSharing -> handleShareMyMatrixToLink()
}.exhaustive
}
@ -168,13 +162,13 @@ class UserListViewModel @AssistedInject constructor(@Assisted initialState: User
.disposeOnClear()
}
private fun handleSelectUser(action: UserListAction.SelectPendingInvitee) = withState { state ->
val selectedUsers = state.pendingInvitees.toggle(action.pendingInvitee)
setState { copy(pendingInvitees = selectedUsers) }
private fun handleSelectUser(action: UserListAction.AddPendingSelection) = withState { state ->
val selections = state.pendingSelections.toggle(action.pendingSelection, singleElement = state.singleSelection)
setState { copy(pendingSelections = selections) }
}
private fun handleRemoveSelectedUser(action: UserListAction.RemovePendingInvitee) = withState { state ->
val selectedUsers = state.pendingInvitees.minus(action.pendingInvitee)
setState { copy(pendingInvitees = selectedUsers) }
private fun handleRemoveSelectedUser(action: UserListAction.RemovePendingSelection) = withState { state ->
val selections = state.pendingSelections.minus(action.pendingSelection)
setState { copy(pendingSelections = selections) }
}
}

View file

@ -28,24 +28,27 @@ data class UserListViewState(
val knownUsers: Async<PagedList<User>> = Uninitialized,
val directoryUsers: Async<List<User>> = Uninitialized,
val filteredMappedContacts: List<MappedContact> = emptyList(),
val pendingInvitees: Set<PendingInvitee> = emptySet(),
val createAndInviteState: Async<String> = Uninitialized,
val pendingSelections: Set<PendingSelection> = emptySet(),
val searchTerm: String = "",
val myUserId: String = "",
val existingRoomId: String? = null
val singleSelection: Boolean,
private val showInviteActions: Boolean
) : MvRxState {
constructor(args: UserListFragmentArgs) : this(
existingRoomId = args.existingRoomId
excludedUserIds = args.excludedUserIds,
singleSelection = args.singleSelection,
showInviteActions = args.showInviteActions
)
fun getSelectedMatrixId(): List<String> {
return pendingInvitees
return pendingSelections
.mapNotNull {
when (it) {
is PendingInvitee.UserPendingInvitee -> it.user.userId
is PendingInvitee.ThreePidPendingInvitee -> null
is PendingSelection.UserPendingSelection -> it.user.userId
is PendingSelection.ThreePidPendingSelection -> null
}
}
}
fun showInviteActions() = showInviteActions && pendingSelections.isEmpty()
}

View file

@ -0,0 +1,17 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<group>
<clip-path
android:pathData="M0,0h24v24h-24z"/>
<path
android:pathData="M8.027,15.9613C9.168,17.1932 11.9148,19.3263 12.6635,19.7641C12.7078,19.79 12.7585,19.8201 12.8152,19.8538C13.9576,20.5329 17.5373,22.6609 20.1454,20.6694C22.1661,19.1266 21.5091,17.3909 20.8289,16.875C20.3633,16.5128 18.9914,15.5145 17.7006,14.6152C16.4331,13.7322 15.7268,14.4397 15.2492,14.918C15.2404,14.9268 15.2317,14.9355 15.2231,14.9442L14.2621,15.9051C14.0174,16.1498 13.6451,16.0605 13.2886,15.7804C12.0092,14.8061 11.0681,13.8659 10.5972,13.395L10.5933,13.391C10.1225,12.9202 9.1939,11.9908 8.2196,10.7114C7.9395,10.3548 7.8502,9.9826 8.0949,9.7379L9.0559,8.7769C9.0645,8.7683 9.0732,8.7596 9.082,8.7508C9.5603,8.2732 10.2678,7.5668 9.3848,6.2994C8.4855,5.0086 7.4872,3.6367 7.125,3.1711C6.6091,2.4909 4.8734,1.8339 3.3306,3.8546C1.3391,6.4627 3.4671,10.0424 4.1462,11.1848C4.1799,11.2415 4.2101,11.2922 4.2359,11.3365C4.6737,12.0851 6.7951,14.8203 8.027,15.9613Z"
android:fillColor="#C1C6CD"/>
<path
android:pathData="M13.3084,9.7333C12.9179,10.1238 12.9179,10.757 13.3084,11.1475C13.6989,11.538 14.3321,11.538 14.7226,11.1475L20.4401,5.43L20.4401,9.9101C20.4401,10.4624 20.8878,10.9101 21.4401,10.9101C21.9924,10.9101 22.4401,10.4624 22.4401,9.9101L22.4401,3.0158C22.4401,2.4635 21.9924,2.0158 21.4401,2.0158H14.5458C13.9935,2.0158 13.5458,2.4635 13.5458,3.0158C13.5458,3.5681 13.9935,4.0158 14.5458,4.0158L19.0259,4.0158L13.3084,9.7333Z"
android:fillColor="#C1C6CD"
android:fillType="evenOdd"/>
</group>
</vector>

View file

@ -14,10 +14,10 @@
<ImageView
android:id="@+id/bgCallView"
tools:src="@tools:sample/avatars"
android:scaleType="centerCrop"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
android:layout_height="match_parent"
android:scaleType="centerCrop"
tools:src="@tools:sample/avatars" />
<org.webrtc.SurfaceViewRenderer
android:id="@+id/fullscreenRenderer"
@ -36,6 +36,35 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<FrameLayout
android:id="@+id/otherKnownCallLayout"
android:layout_width="80dp"
android:layout_height="144dp"
android:layout_marginTop="32dp"
android:layout_marginEnd="16dp"
android:visibility="gone"
tools:visibility="visible"
android:background="@color/riotx_background_light"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent">
<ImageView
android:id="@+id/otherKnownCallAvatarView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:foreground="?attr/selectableItemBackground"
android:scaleType="centerCrop"
tools:src="@tools:sample/avatars" />
<ImageView
android:id="@+id/otherSmallIsHeldIcon"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_gravity="center"
android:src="@drawable/ic_call_small_pause" />
</FrameLayout>
<ImageView
android:id="@+id/otherMemberAvatar"
android:layout_width="80dp"
@ -49,14 +78,14 @@
tools:src="@tools:sample/avatars" />
<ImageView
android:id="@+id/smallIsHeldIcon"
android:layout_width="20dp"
android:layout_height="20dp"
android:id="@+id/smallIsHeldIcon"
android:src="@drawable/ic_call_small_pause"
app:layout_constraintTop_toTopOf="@id/otherMemberAvatar"
app:layout_constraintBottom_toBottomOf="@id/otherMemberAvatar"
app:layout_constraintEnd_toEndOf="@id/otherMemberAvatar"
app:layout_constraintStart_toStartOf="@id/otherMemberAvatar"
app:layout_constraintEnd_toEndOf="@id/otherMemberAvatar" />
app:layout_constraintTop_toTopOf="@id/otherMemberAvatar" />
<TextView
@ -95,10 +124,10 @@
android:id="@+id/callActionText"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_margin="8dp"
android:gravity="center"
android:textColor="?attr/colorAccent"
android:textSize="14sp"
android:layout_margin="8dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/callStatusText"

View file

@ -0,0 +1,76 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/vector_coordinator_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.appcompat.widget.Toolbar
android:id="@+id/callTransferToolbar"
style="@style/VectorToolbarStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:elevation="4dp"
app:layout_constraintTop_toTopOf="parent" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/callTransferFragmentContainer"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@+id/callTransferActionsLayout"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/callTransferToolbar" />
<RelativeLayout
android:background="?riotx_header_panel_background"
android:id="@+id/callTransferActionsLayout"
android:layout_width="match_parent"
android:paddingVertical="8dp"
android:paddingHorizontal="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintStart_toEndOf="parent"
android:layout_height="wrap_content">
<CheckBox
android:id="@+id/callTransferConsultCheckBox"
android:layout_width="wrap_content"
android:layout_centerVertical="true"
android:layout_alignParentStart="true"
android:enabled="false"
android:layout_height="wrap_content"/>
<TextView
android:id="@+id/callTransferConsultTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:layout_toEndOf="@id/callTransferConsultCheckBox"
android:layout_toStartOf="@+id/callTransferConnectAction"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:text="@string/call_transfer_consult_first" />
<Button
android:id="@+id/callTransferConnectAction"
style="@style/VectorButtonStyleText"
android:layout_width="wrap_content"
android:layout_centerVertical="true"
android:layout_alignParentEnd="true"
android:text="@string/call_transfer_connect_action"
android:layout_height="wrap_content"/>
</RelativeLayout>
<include
android:id="@+id/waiting_view"
layout="@layout/merge_overlay_waiting_view" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -4,6 +4,8 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingTop="4dp"
android:paddingBottom="4dp"
xmlns:tools="http://schemas.android.com/tools"
tools:style="@style/AlertStyle"
xmlns:app="http://schemas.android.com/apk/res-auto">
@ -43,6 +45,8 @@
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:textColor="?riotx_text_secondary"
app:drawableTint="?riotx_text_secondary"
android:drawablePadding="4dp"
android:textSize="15sp"
android:maxLines="1"
app:layout_constraintEnd_toStartOf="@+id/incomingCallRejectView"
@ -63,6 +67,7 @@
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:tint="@color/white"
android:src="@drawable/ic_call_answer" />
<ImageView

View file

@ -44,4 +44,13 @@
app:tint="?attr/riotx_text_primary"
tools:actionDescription="" />
<im.vector.app.core.ui.views.BottomSheetActionButton
android:id="@+id/callControlsTransfer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:actionTitle="@string/call_transfer_title"
app:leftIcon="@drawable/ic_call_transfer"
app:tint="?attr/riotx_text_primary"
tools:actionDescription="" />
</LinearLayout>

View file

@ -65,7 +65,7 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/syncStateView" />
<im.vector.app.core.ui.views.ActiveCallView
<im.vector.app.core.ui.views.CurrentCallsView
android:id="@+id/activeCallView"
android:layout_width="match_parent"
android:layout_height="wrap_content"

View file

@ -96,7 +96,7 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/roomToolbar" />
<im.vector.app.core.ui.views.ActiveCallView
<im.vector.app.core.ui.views.CurrentCallsView
android:id="@+id/activeCallView"
android:layout_width="match_parent"
android:layout_height="wrap_content"

View file

@ -9,38 +9,38 @@
tools:parentTag="android.widget.RelativeLayout">
<TextView
android:id="@+id/activeCallInfo"
android:id="@+id/currentCallsInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_toStartOf="@id/returnToCallButton"
android:background="?attr/selectableItemBackground"
android:drawablePadding="10dp"
android:gravity="center_vertical"
android:paddingStart="16dp"
android:paddingTop="12dp"
android:paddingEnd="16dp"
android:paddingBottom="12dp"
android:text="@string/active_call"
android:textSize="14sp"
android:text="@string/call_only_active"
android:textColor="@color/white"
app:drawableTint="@color/white"
app:drawableStartCompat="@drawable/ic_call" />
app:drawableStartCompat="@drawable/ic_call_answer" />
<com.google.android.material.button.MaterialButton
android:id="@+id/returnToCallButton"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignTop="@+id/activeCallInfo"
android:layout_alignBottom="@+id/activeCallInfo"
android:layout_alignTop="@+id/currentCallsInfo"
android:layout_alignBottom="@+id/currentCallsInfo"
android:layout_alignParentEnd="true"
android:clickable="false"
android:focusable="false"
android:gravity="center"
android:paddingStart="8dp"
android:paddingEnd="16dp"
android:text="@string/return_to_call"
android:text="@string/action_return"
android:textColor="@color/white"
android:textSize="15sp"
android:textSize="14sp"
android:textStyle="bold" />
</merge>

View file

@ -141,6 +141,7 @@
<string name="action_unpublish">Unpublish</string>
<string name="copied_to_clipboard">Copied to clipboard</string>
<string name="disable">Disable</string>
<string name="action_return">Return</string>
<!-- dialog titles -->
<string name="dialog_title_confirmation">Confirmation</string>
@ -2773,4 +2774,23 @@
<string name="call_tile_other_declined">%1$s declined this call</string>
<string name="call_tile_ended">This call has ended</string>
<string name="call_tile_call_back">Call back</string>
<string name="call_only_active">Active call (%1$s)</string>
<plurals name="call_only_paused">
<item quantity="one">Paused call</item>
<item quantity="other">%1$d paused calls</item>
</plurals>
<plurals name="call_one_active_and_other_paused">
<item quantity="one">1 active call (%1$s) · 1 paused call</item>
<item quantity="other">1 active call (%1$s) · %2$d paused calls</item>
</plurals>
<string name="call_transfer_consult_first">Consult first</string>
<string name="call_transfer_connect_action">Connect</string>
<string name="call_transfer_title">Transfer</string>
<string name="call_transfer_failure">An error occured while transfering call</string>
</resources>