diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt index 725fd08d3b..93a1b962ed 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt @@ -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 ) { /** diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/call/MxCall.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/call/MxCall.kt index 75cff0e709..2b7c0a7578 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/call/MxCall.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/call/MxCall.kt @@ -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? var opponentVersion: Int + var capabilities: CallCapabilities? + var state: CallState /** @@ -86,6 +89,11 @@ interface MxCall : MxCallDetail { */ fun sendLocalIceCandidateRemovals(candidates: List) + /** + * 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) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt index 85ba3100b0..3a4b4f82c3 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt @@ -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" diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallAnswerContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallAnswerContent.kt index 1fe4c3576f..45a54bb379 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallAnswerContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallAnswerContent.kt @@ -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) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallCapabilities.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallCapabilities.kt new file mode 100644 index 0000000000..d911ca3b88 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallCapabilities.kt @@ -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() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallInviteContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallInviteContent.kt index dfbc5c64be..42489bc0ce 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallInviteContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallInviteContent.kt @@ -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) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallReplacesContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallReplacesContent.kt new file mode 100644 index 0000000000..97a3b8c7a7 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallReplacesContent.kt @@ -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? + + ) +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt index 73cb94b417..4216b03a87 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt @@ -52,6 +52,8 @@ data class TimelineEvent( } } + val roomId = root.roomId ?: "" + val metadata = HashMap() /** diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallSignalingHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallSignalingHandler.kt index a6de65f9f5..4576675d4a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallSignalingHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallSignalingHandler.kt @@ -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) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/MxCallFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/MxCallFactory.kt index 3c258df31a..4e60de6fd3 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/MxCallFactory.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/MxCallFactory.kt @@ -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 ) } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/model/MxCallImpl.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/model/MxCallImpl.kt index 9368e94efc..ab7b74c229 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/model/MxCallImpl.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/model/MxCallImpl.kt @@ -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? = 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 + } + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/queue/QueueMemento.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/queue/QueueMemento.kt index dfbac347d9..a6836c8086 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/queue/QueueMemento.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/queue/QueueMemento.kt @@ -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) diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml index 5035811b24..d1289563e9 100644 --- a/vector/src/main/AndroidManifest.xml +++ b/vector/src/main/AndroidManifest.xml @@ -243,6 +243,7 @@ + diff --git a/vector/src/main/java/im/vector/app/core/di/ScreenComponent.kt b/vector/src/main/java/im/vector/app/core/di/ScreenComponent.kt index f56a6a3d70..dc4a236f08 100644 --- a/vector/src/main/java/im/vector/app/core/di/ScreenComponent.kt +++ b/vector/src/main/java/im/vector/app/core/di/ScreenComponent.kt @@ -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 diff --git a/vector/src/main/java/im/vector/app/core/di/VectorComponent.kt b/vector/src/main/java/im/vector/app/core/di/VectorComponent.kt index fb8b1eac07..23d6b618fe 100644 --- a/vector/src/main/java/im/vector/app/core/di/VectorComponent.kt +++ b/vector/src/main/java/im/vector/app/core/di/VectorComponent.kt @@ -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 diff --git a/vector/src/main/java/im/vector/app/core/di/ViewModelModule.kt b/vector/src/main/java/im/vector/app/core/di/ViewModelModule.kt index bed2e0b850..8409021845 100644 --- a/vector/src/main/java/im/vector/app/core/di/ViewModelModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/ViewModelModule.kt @@ -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 diff --git a/vector/src/main/java/im/vector/app/core/extensions/Set.kt b/vector/src/main/java/im/vector/app/core/extensions/Set.kt index a78fb85a1d..e1787076b9 100644 --- a/vector/src/main/java/im/vector/app/core/extensions/Set.kt +++ b/vector/src/main/java/im/vector/app/core/extensions/Set.kt @@ -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 Set.toggle(element: T): Set { +fun Set.toggle(element: T, singleElement: Boolean = false): Set { return if (contains(element)) { - minus(element) + if (singleElement) { + emptySet() + } else { + minus(element) + } } else { - plus(element) + if (singleElement) { + setOf(element) + } else { + plus(element) + } } } diff --git a/vector/src/main/java/im/vector/app/core/platform/VectorBaseActivity.kt b/vector/src/main/java/im/vector/app/core/platform/VectorBaseActivity.kt index a585e8ea77..7ec04e32e8 100644 --- a/vector/src/main/java/im/vector/app/core/platform/VectorBaseActivity.kt +++ b/vector/src/main/java/im/vector/app/core/platform/VectorBaseActivity.kt @@ -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 : AppCompatActivity(), HasScreenInjector { @@ -113,6 +115,18 @@ abstract class VectorBaseActivity : AppCompatActivity(), HasScr .disposeOnDestroy() } + /* ========================================================================================== + * Views + * ========================================================================================== */ + + protected fun View.debouncedClicks(onClicked: () -> Unit) { + clicks() + .throttleFirst(300, TimeUnit.MILLISECONDS) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { onClicked() } + .disposeOnDestroy() + } + /* ========================================================================================== * DATA * ========================================================================================== */ diff --git a/vector/src/main/java/im/vector/app/core/services/CallService.kt b/vector/src/main/java/im/vector/app/core/services/CallService.kt index a5a59dc0ba..7571b6a205 100644 --- a/vector/src/main/java/im/vector/app/core/services/CallService.kt +++ b/vector/src/main/java/im/vector/app/core/services/CallService.kt @@ -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() + private val knownCalls = mutableSetOf() + 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) } diff --git a/vector/src/main/java/im/vector/app/core/ui/views/ActiveCallView.kt b/vector/src/main/java/im/vector/app/core/ui/views/CurrentCallsView.kt similarity index 50% rename from vector/src/main/java/im/vector/app/core/ui/views/ActiveCallView.kt rename to vector/src/main/java/im/vector/app/core/ui/views/CurrentCallsView.kt index 19d1fbb6f6..d1332f18dc 100644 --- a/vector/src/main/java/im/vector/app/core/ui/views/ActiveCallView.kt +++ b/vector/src/main/java/im/vector/app/core/ui/views/CurrentCallsView.kt @@ -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, 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) + } + } + } } diff --git a/vector/src/main/java/im/vector/app/core/ui/views/ActiveCallViewHolder.kt b/vector/src/main/java/im/vector/app/core/ui/views/KnownCallsViewHolder.kt similarity index 61% rename from vector/src/main/java/im/vector/app/core/ui/views/ActiveCallViewHolder.kt rename to vector/src/main/java/im/vector/app/core/ui/views/KnownCallsViewHolder.kt index 193a4b2387..3bd9ce713d 100644 --- a/vector/src/main/java/im/vector/app/core/ui/views/ActiveCallViewHolder.kt +++ b/vector/src/main/java/im/vector/app/core/ui/views/KnownCallsViewHolder.kt @@ -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 = 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) { + 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 } } diff --git a/vector/src/main/java/im/vector/app/core/utils/CountUpTimer.kt b/vector/src/main/java/im/vector/app/core/utils/CountUpTimer.kt new file mode 100644 index 0000000000..2a9482765c --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/utils/CountUpTimer.kt @@ -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) + } +} diff --git a/vector/src/main/java/im/vector/app/features/call/CallControlsBottomSheet.kt b/vector/src/main/java/im/vector/app/features/call/CallControlsBottomSheet.kt index a94a030e34..eab788f461 100644 --- a/vector/src/main/java/im/vector/app/features/call/CallControlsBottomSheet.kt +++ b/vector/src/main/java/im/vector/app/features/call/CallControlsBottomSheet.kt @@ -63,6 +63,11 @@ class CallControlsBottomSheet : VectorBaseBottomSheetDialogFragment { @@ -153,5 +158,6 @@ class CallControlsBottomSheet : VectorBaseBottomSheetDialogFragment = MutableLiveData() + val liveKnownCalls: MutableLiveData> = 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() } } diff --git a/vector/src/main/java/im/vector/app/features/call/VectorCallActivity.kt b/vector/src/main/java/im/vector/app/features/call/VectorCallActivity.kt index c9a078e260..5fb2d99ff6 100644 --- a/vector/src/main/java/im/vector/app/features/call/VectorCallActivity.kt +++ b/vector/src/main/java/im/vector/app/features/call/VectorCallActivity.kt @@ -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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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, grantResults: IntArray) { @@ -295,12 +315,14 @@ class VectorCallActivity : VectorBaseActivity(), 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 -> { } } diff --git a/vector/src/main/java/im/vector/app/features/call/VectorCallViewActions.kt b/vector/src/main/java/im/vector/app/features/call/VectorCallViewActions.kt index 83ac878186..adb8897a51 100644 --- a/vector/src/main/java/im/vector/app/features/call/VectorCallViewActions.kt +++ b/vector/src/main/java/im/vector/app/features/call/VectorCallViewActions.kt @@ -30,4 +30,5 @@ sealed class VectorCallViewActions : VectorViewModelAction { object HeadSetButtonPressed : VectorCallViewActions() object ToggleCamera : VectorCallViewActions() object ToggleHDSD : VectorCallViewActions() + object InitiateCallTransfer : VectorCallViewActions() } diff --git a/vector/src/main/java/im/vector/app/features/call/VectorCallViewEvents.kt b/vector/src/main/java/im/vector/app/features/call/VectorCallViewEvents.kt index b79cd5d772..832c4fe944 100644 --- a/vector/src/main/java/im/vector/app/features/call/VectorCallViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/call/VectorCallViewEvents.kt @@ -27,6 +27,7 @@ sealed class VectorCallViewEvents : VectorViewEvents { val available: List, 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() diff --git a/vector/src/main/java/im/vector/app/features/call/VectorCallViewModel.kt b/vector/src/main/java/im/vector/app/features/call/VectorCallViewModel.kt index e1a01bbaa3..0c621dcfe4 100644 --- a/vector/src/main/java/im/vector/app/features/call/VectorCallViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/call/VectorCallViewModel.kt @@ -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 } diff --git a/vector/src/main/java/im/vector/app/features/call/VectorCallViewState.kt b/vector/src/main/java/im/vector/app/features/call/VectorCallViewState.kt index 8742e2cc7c..1d12b780b1 100644 --- a/vector/src/main/java/im/vector/app/features/call/VectorCallViewState.kt +++ b/vector/src/main/java/im/vector/app/features/call/VectorCallViewState.kt @@ -36,10 +36,18 @@ data class VectorCallViewState( val canSwitchCamera: Boolean = true, val soundDevice: CallAudioManager.SoundDevice = CallAudioManager.SoundDevice.PHONE, val availableSoundDevices: List = emptyList(), - val otherUserMatrixItem: Async = Uninitialized, - val callState: Async = Uninitialized + val callState: Async = 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, diff --git a/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferAction.kt b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferAction.kt new file mode 100644 index 0000000000..cbdd9c252b --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferAction.kt @@ -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() +} diff --git a/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferActivity.kt b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferActivity.kt new file mode 100644 index 0000000000..89a7caa764 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferActivity.kt @@ -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(), + 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, 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)) + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferViewEvents.kt b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferViewEvents.kt new file mode 100644 index 0000000000..b110164d1e --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferViewEvents.kt @@ -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() +} diff --git a/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferViewModel.kt b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferViewModel.kt new file mode 100644 index 0000000000..0b83a6bcda --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferViewModel.kt @@ -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(initialState) { + + @AssistedInject.Factory + interface Factory { + fun create(initialState: CallTransferViewState): CallTransferViewModel + } + + companion object : MvRxViewModelFactory { + + @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) + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferViewState.kt b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferViewState.kt new file mode 100644 index 0000000000..2b29d9f6f2 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferViewState.kt @@ -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) +} diff --git a/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCall.kt b/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCall.kt index 10c7cb2e24..28ca503507 100644 --- a/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCall.kt +++ b/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCall.kt @@ -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, private val peerConnectionFactoryProvider: Provider, + 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() + private val listeners = CopyOnWriteArrayList() 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().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?) { 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.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) } } diff --git a/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCallManager.kt b/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCallManager.kt index f6fcfd446e..18720a6c19 100644 --- a/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCallManager.kt +++ b/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCallManager.kt @@ -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().toMutableList() + private val currentCallsListeners = CopyOnWriteArrayList() 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 = AtomicReference(null) + private fun AtomicReference.setAndNotify(newValue: WebRtcCall?) { + set(newValue) + currentCallsListeners.forEach { + tryOrNull { it.onCurrentCallChange(newValue) } } + } - private val callsByCallId = HashMap() - private val callsByRoomId = HashMap>() + private val callsByCallId = ConcurrentHashMap() + private val callsByRoomId = ConcurrentHashMap>() 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 { + 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) { diff --git a/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookFragment.kt b/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookFragment.kt index 6aaa69fbc0..68e169b8c5 100644 --- a/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookFragment.kt +++ b/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookFragment.kt @@ -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(), ContactsBookController.Callback { +) : VectorBaseFragment(), 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) } } diff --git a/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookViewModel.kt b/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookViewModel.kt index 2c4c5d0596..b8cdf08234 100644 --- a/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookViewModel.kt @@ -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 { 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()) { - is CreateDirectRoomActivity -> viewModelContext.activity().contactsBookViewModelFactory.create(state) - is InviteUsersToRoomActivity -> viewModelContext.activity().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") } } diff --git a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomAction.kt b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomAction.kt index ce91761fdd..ffc25210e9 100644 --- a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomAction.kt +++ b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomAction.kt @@ -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, + val selections: Set, val existingDmRoomId: String? ) : CreateDirectRoomAction() } diff --git a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomActivity.kt b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomActivity.kt index beb7931fd4..4f81841b73 100644 --- a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomActivity.kt +++ b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomActivity.kt @@ -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 )) } diff --git a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomByQrCodeFragment.kt b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomByQrCodeFragment.kt index 94578ed5c7..92a03c5483 100644 --- a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomByQrCodeFragment.kt +++ b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomByQrCodeFragment.kt @@ -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) ) } } diff --git a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewModel.kt b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewModel.kt index d074c93587..9ac8ed2450 100644 --- a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewModel.kt @@ -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) { + private fun createRoomAndInviteSelectedUsers(selections: Set) { 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() diff --git a/vector/src/main/java/im/vector/app/features/home/HomeDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/HomeDetailFragment.kt index d344f1bd86..4c7b7aa991 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeDetailFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeDetailFragment.kt @@ -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(), 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, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt index 988cf2fd30..ffd50f180b 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt @@ -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(), 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, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt index a8cd200814..05502e9e90 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt @@ -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()) { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt index dd234266b1..018b8691c0 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt @@ -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 } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt index 716fdca2ad..3684f19b61 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt @@ -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 } ?: "" diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/CallItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/CallItemFactory.kt index 8c972a8684..3992bd7bed 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/CallItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/CallItemFactory.kt @@ -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, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt index 23bd041e95..2134645d8d 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt @@ -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() private val mergeItemCollapseStates = HashMap() @@ -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, @@ -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() } ?.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 diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt index a1e041b98f..2c6176e87e 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt @@ -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()?.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 } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/NoticeItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/NoticeItemFactory.kt index ec065543f5..cd8c682f39 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/NoticeItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/NoticeItemFactory.kt @@ -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, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/RoomCreateItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/RoomCreateItemFactory.kt index 25b5fd718b..31adbdb8a6 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/RoomCreateItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/RoomCreateItemFactory.kt @@ -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 } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt index 432736dba0..bd045e166a 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt @@ -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 } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VerificationItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VerificationItemFactory.kt index 59daf5a0a0..0b623d78f1 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VerificationItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VerificationItemFactory.kt @@ -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 } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt index f4632b0e10..499e27f838 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt @@ -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" } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt index c725f5b7dc..9b45adbe99 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt @@ -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) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt index 8a8bf364e1..802c177197 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt @@ -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() diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/RoomSummaryHolder.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/RoomSummariesHolder.kt similarity index 63% rename from vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/RoomSummaryHolder.kt rename to vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/RoomSummariesHolder.kt index d3ae091733..ac953f91f7 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/RoomSummaryHolder.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/RoomSummariesHolder.kt @@ -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() 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() } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt index 7bb9940ec4..06cb0172d0 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt @@ -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) diff --git a/vector/src/main/java/im/vector/app/features/invite/InviteUsersToRoomAction.kt b/vector/src/main/java/im/vector/app/features/invite/InviteUsersToRoomAction.kt index cd9df8dc96..be9ad61868 100644 --- a/vector/src/main/java/im/vector/app/features/invite/InviteUsersToRoomAction.kt +++ b/vector/src/main/java/im/vector/app/features/invite/InviteUsersToRoomAction.kt @@ -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) : InviteUsersToRoomAction() + data class InviteSelectedUsers(val selections: Set) : InviteUsersToRoomAction() } diff --git a/vector/src/main/java/im/vector/app/features/invite/InviteUsersToRoomActivity.kt b/vector/src/main/java/im/vector/app/features/invite/InviteUsersToRoomActivity.kt index 9949255e32..f9f5b2b995 100644 --- a/vector/src/main/java/im/vector/app/features/invite/InviteUsersToRoomActivity.kt +++ b/vector/src/main/java/im/vector/app/features/invite/InviteUsersToRoomActivity.kt @@ -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)) } } diff --git a/vector/src/main/java/im/vector/app/features/invite/InviteUsersToRoomViewModel.kt b/vector/src/main/java/im/vector/app/features/invite/InviteUsersToRoomViewModel.kt index 21a998d8e2..c0e9ea6d51 100644 --- a/vector/src/main/java/im/vector/app/features/invite/InviteUsersToRoomViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/invite/InviteUsersToRoomViewModel.kt @@ -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) { + private fun inviteUsersToRoom(selections: Set) { _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)) }, diff --git a/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt b/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt index 0a6197e424..991f776a45 100644 --- a/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt +++ b/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt @@ -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) diff --git a/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt b/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt index 504fccb63a..bc13e99bbf 100644 --- a/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt +++ b/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt @@ -113,4 +113,6 @@ interface Navigator { options: ((MutableList>) -> Unit)?) fun openSearch(context: Context, roomId: String) + + fun openCallTransfer(context: Context, callId: String) } diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt b/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt index 9c2dc9b26d..c1bb1dde36 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt @@ -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 diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt b/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt index 74a93fceda..d1e52b2a78 100755 --- a/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt @@ -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() diff --git a/vector/src/main/java/im/vector/app/features/popup/IncomingCallAlert.kt b/vector/src/main/java/im/vector/app/features/popup/IncomingCallAlert.kt index 64e729e54d..f80d6d1bbd 100644 --- a/vector/src/main/java/im/vector/app/features/popup/IncomingCallAlert.kt +++ b/vector/src/main/java/im/vector/app/features/popup/IncomingCallAlert.kt @@ -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(R.id.incomingCallKindView).apply { + setText(callKindText) + setLeftDrawable(callKindIcon) } - view.findViewById(R.id.incomingCallKindView).setText(callKind) view.findViewById(R.id.incomingCallNameView).text = matrixItem?.getBestName() view.findViewById(R.id.incomingCallAvatar)?.let { imageView -> - matrixItem?.let { avatarRenderer.render(it, imageView) } + matrixItem?.let { avatarRenderer.render(it, imageView, GlideApp.with(view.context.applicationContext)) } } - view.findViewById(R.id.incomingCallAcceptView).setOnClickListener { - onAccept() + view.findViewById(R.id.incomingCallAcceptView).apply { + setOnClickListener { + onAccept() + } + setImageResource(callKindIcon) } view.findViewById(R.id.incomingCallRejectView).setOnClickListener { onReject() diff --git a/vector/src/main/java/im/vector/app/features/popup/VerificationVectorAlert.kt b/vector/src/main/java/im/vector/app/features/popup/VerificationVectorAlert.kt index 2cd9ab59ac..ee6728f969 100644 --- a/vector/src/main/java/im/vector/app/features/popup/VerificationVectorAlert.kt +++ b/vector/src/main/java/im/vector/app/features/popup/VerificationVectorAlert.kt @@ -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(R.id.ivUserAvatar)?.let { imageView -> - matrixItem?.let { avatarRenderer.render(it, imageView) } + matrixItem?.let { avatarRenderer.render(it, imageView, GlideApp.with(view.context.applicationContext)) } } } } diff --git a/vector/src/main/java/im/vector/app/features/userdirectory/PendingInvitee.kt b/vector/src/main/java/im/vector/app/features/userdirectory/PendingSelection.kt similarity index 73% rename from vector/src/main/java/im/vector/app/features/userdirectory/PendingInvitee.kt rename to vector/src/main/java/im/vector/app/features/userdirectory/PendingSelection.kt index f7213497fa..57f950b2c8 100644 --- a/vector/src/main/java/im/vector/app/features/userdirectory/PendingInvitee.kt +++ b/vector/src/main/java/im/vector/app/features/userdirectory/PendingSelection.kt @@ -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 } } } diff --git a/vector/src/main/java/im/vector/app/features/userdirectory/UserListAction.kt b/vector/src/main/java/im/vector/app/features/userdirectory/UserListAction.kt index 0c2c4b1f4b..7835232b09 100644 --- a/vector/src/main/java/im/vector/app/features/userdirectory/UserListAction.kt +++ b/vector/src/main/java/im/vector/app/features/userdirectory/UserListAction.kt @@ -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() } diff --git a/vector/src/main/java/im/vector/app/features/userdirectory/UserListController.kt b/vector/src/main/java/im/vector/app/features/userdirectory/UserListController.kt index 3e1523d0cc..331329ae61 100644 --- a/vector/src/main/java/im/vector/app/features/userdirectory/UserListController.kt +++ b/vector/src/main/java/im/vector/app/features/userdirectory/UserListController.kt @@ -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)) diff --git a/vector/src/main/java/im/vector/app/features/userdirectory/UserListFragment.kt b/vector/src/main/java/im/vector/app/features/userdirectory/UserListFragment.kt index d06030c301..96f459cbbf 100644 --- a/vector/src/main/java/im/vector/app/features/userdirectory/UserListFragment.kt +++ b/vector/src/main/java/im/vector/app/features/userdirectory/UserListFragment.kt @@ -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) { + private fun renderSelectedUsers(selections: Set) { 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() { diff --git a/vector/src/main/java/im/vector/app/features/userdirectory/UserListFragmentArgs.kt b/vector/src/main/java/im/vector/app/features/userdirectory/UserListFragmentArgs.kt index 02fd13b39b..dce1f46b2f 100644 --- a/vector/src/main/java/im/vector/app/features/userdirectory/UserListFragmentArgs.kt +++ b/vector/src/main/java/im/vector/app/features/userdirectory/UserListFragmentArgs.kt @@ -24,5 +24,7 @@ data class UserListFragmentArgs( val title: String, val menuResId: Int, val excludedUserIds: Set? = null, - val existingRoomId: String? = null + val singleSelection: Boolean = false, + val showInviteActions: Boolean = true, + val showToolbar: Boolean = true ) : Parcelable diff --git a/vector/src/main/java/im/vector/app/features/userdirectory/UserListSharedAction.kt b/vector/src/main/java/im/vector/app/features/userdirectory/UserListSharedAction.kt index b2cdee3e63..fca771793b 100644 --- a/vector/src/main/java/im/vector/app/features/userdirectory/UserListSharedAction.kt +++ b/vector/src/main/java/im/vector/app/features/userdirectory/UserListSharedAction.kt @@ -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) : UserListSharedAction() + data class OnMenuItemSelected(val itemId: Int, val selections: Set) : UserListSharedAction() object OpenPhoneBook : UserListSharedAction() object AddByQrCode : UserListSharedAction() } diff --git a/vector/src/main/java/im/vector/app/features/userdirectory/UserListViewModel.kt b/vector/src/main/java/im/vector/app/features/userdirectory/UserListViewModel.kt index f8eabbaed0..322f5eef21 100644 --- a/vector/src/main/java/im/vector/app/features/userdirectory/UserListViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/userdirectory/UserListViewModel.kt @@ -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) } } } diff --git a/vector/src/main/java/im/vector/app/features/userdirectory/UserListViewState.kt b/vector/src/main/java/im/vector/app/features/userdirectory/UserListViewState.kt index 69135f912d..60ba3a17da 100644 --- a/vector/src/main/java/im/vector/app/features/userdirectory/UserListViewState.kt +++ b/vector/src/main/java/im/vector/app/features/userdirectory/UserListViewState.kt @@ -28,24 +28,27 @@ data class UserListViewState( val knownUsers: Async> = Uninitialized, val directoryUsers: Async> = Uninitialized, val filteredMappedContacts: List = emptyList(), - val pendingInvitees: Set = emptySet(), - val createAndInviteState: Async = Uninitialized, + val pendingSelections: Set = 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 { - 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() } diff --git a/vector/src/main/res/drawable/ic_call_transfer.xml b/vector/src/main/res/drawable/ic_call_transfer.xml new file mode 100644 index 0000000000..b2c28d6ba0 --- /dev/null +++ b/vector/src/main/res/drawable/ic_call_transfer.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/vector/src/main/res/layout/activity_call.xml b/vector/src/main/res/layout/activity_call.xml index b82915d383..45be67ae8e 100644 --- a/vector/src/main/res/layout/activity_call.xml +++ b/vector/src/main/res/layout/activity_call.xml @@ -14,10 +14,10 @@ + android:layout_height="match_parent" + android:scaleType="centerCrop" + tools:src="@tools:sample/avatars" /> + + + + + + + + + app:layout_constraintTop_toTopOf="@id/otherMemberAvatar" /> + + + + + + + + + + + + + + +