diff --git a/changelog.d/7273.wip b/changelog.d/7273.wip new file mode 100644 index 0000000000..c480a79a43 --- /dev/null +++ b/changelog.d/7273.wip @@ -0,0 +1 @@ +[Voice Broadcast] Add the "io.element.voice_broadcast_info" state event with a minimalist timeline widget diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageType.kt index b12d9ed6c8..e97a5be303 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageType.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageType.kt @@ -43,4 +43,7 @@ object MessageType { // Fake message types for live location events to be able to inherit them from MessageContent const val MSGTYPE_BEACON_INFO = "org.matrix.android.sdk.beacon.info" const val MSGTYPE_BEACON_LOCATION_DATA = "org.matrix.android.sdk.beacon.location.data" + + // Fake message types for voice broadcast events to be able to inherit them from MessageContent + const val MSGTYPE_VOICE_BROADCAST_INFO = "io.element.voicebroadcast.info" } diff --git a/vector/src/main/java/im/vector/app/core/extensions/TimelineEvent.kt b/vector/src/main/java/im/vector/app/core/extensions/TimelineEvent.kt index 28c1587b1a..cdb84387ce 100644 --- a/vector/src/main/java/im/vector/app/core/extensions/TimelineEvent.kt +++ b/vector/src/main/java/im/vector/app/core/extensions/TimelineEvent.kt @@ -16,9 +16,14 @@ package im.vector.app.core.extensions +import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO +import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent 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.message.MessageContent import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent fun TimelineEvent.canReact(): Boolean { // Only event of type EventType.MESSAGE, EventType.STICKER and EventType.POLL_START are supported for the moment @@ -26,3 +31,15 @@ fun TimelineEvent.canReact(): Boolean { root.sendState == SendState.SYNCED && !root.isRedacted() } + +/** + * Get last MessageContent, after a possible edition. + * This method iterate on the vector event types and fallback to [getLastMessageContent] from the matrix sdk for the other types. + */ +fun TimelineEvent.getVectorLastMessageContent(): MessageContent? { + // Iterate on event types which are not part of the matrix sdk, otherwise fallback to the sdk method + return when (root.getClearType()) { + STATE_ROOM_VOICE_BROADCAST_INFO -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel() + else -> getLastMessageContent() + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt index 10708d2290..3e828f62b7 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt @@ -79,7 +79,6 @@ sealed class RoomDetailAction : VectorViewModelAction { data class ReRequestKeys(val eventId: String) : RoomDetailAction() object SelectStickerAttachment : RoomDetailAction() - object StartVoiceBroadcast : RoomDetailAction() object OpenIntegrationManager : RoomDetailAction() object ManageIntegrations : RoomDetailAction() data class AddJitsiWidget(val withVideo: Boolean) : RoomDetailAction() @@ -120,4 +119,11 @@ sealed class RoomDetailAction : VectorViewModelAction { object StopLiveLocationSharing : RoomDetailAction() object OpenElementCallWidget : RoomDetailAction() + + sealed class VoiceBroadcastAction : RoomDetailAction() { + object Start : VoiceBroadcastAction() + object Pause : VoiceBroadcastAction() + object Resume : VoiceBroadcastAction() + object Stop : VoiceBroadcastAction() + } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt index 4bed477711..511fd597fe 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt @@ -65,6 +65,7 @@ import im.vector.app.features.raw.wellknown.withElementWellKnown import im.vector.app.features.session.coroutineScope import im.vector.app.features.settings.VectorDataStore import im.vector.app.features.settings.VectorPreferences +import im.vector.app.features.voicebroadcast.VoiceBroadcastHelper import im.vector.lib.core.utils.flow.chunk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow @@ -149,6 +150,7 @@ class TimelineViewModel @AssistedInject constructor( buildMeta: BuildMeta, timelineFactory: TimelineFactory, private val spaceStateHandler: SpaceStateHandler, + private val voiceBroadcastHelper: VoiceBroadcastHelper, ) : VectorViewModel(initialState), Timeline.Listener, ChatEffectManager.Delegate, CallProtocolsChecker.Listener, LocationSharingServiceConnection.Callback { @@ -456,7 +458,7 @@ class TimelineViewModel @AssistedInject constructor( is RoomDetailAction.ReRequestKeys -> handleReRequestKeys(action) is RoomDetailAction.TapOnFailedToDecrypt -> handleTapOnFailedToDecrypt(action) is RoomDetailAction.SelectStickerAttachment -> handleSelectStickerAttachment() - is RoomDetailAction.StartVoiceBroadcast -> handleStartVoiceBroadcast() + is RoomDetailAction.VoiceBroadcastAction -> handleVoiceBroadcastAction(action) is RoomDetailAction.OpenIntegrationManager -> handleOpenIntegrationManager() is RoomDetailAction.StartCall -> handleStartCall(action) is RoomDetailAction.AcceptCall -> handleAcceptCall(action) @@ -598,9 +600,16 @@ class TimelineViewModel @AssistedInject constructor( } } - private fun handleStartVoiceBroadcast() { - // Todo implement start voice broadcast action - Timber.d("Start voice broadcast clicked") + private fun handleVoiceBroadcastAction(action: RoomDetailAction.VoiceBroadcastAction) { + if (room == null) return + viewModelScope.launch { + when (action) { + RoomDetailAction.VoiceBroadcastAction.Start -> voiceBroadcastHelper.startVoiceBroadcast(room.roomId) + RoomDetailAction.VoiceBroadcastAction.Pause -> voiceBroadcastHelper.pauseVoiceBroadcast(room.roomId) + RoomDetailAction.VoiceBroadcastAction.Resume -> voiceBroadcastHelper.resumeVoiceBroadcast(room.roomId) + RoomDetailAction.VoiceBroadcastAction.Stop -> voiceBroadcastHelper.stopVoiceBroadcast(room.roomId) + } + } } private fun handleOpenIntegrationManager() { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt index 21a87f092f..3f58a4a184 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt @@ -48,6 +48,7 @@ import com.vanniktech.emoji.EmojiPopup import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R import im.vector.app.core.error.fatalError +import im.vector.app.core.extensions.getVectorLastMessageContent import im.vector.app.core.extensions.registerStartForActivityResult import im.vector.app.core.extensions.showKeyboard import im.vector.app.core.glide.GlideApp @@ -73,6 +74,7 @@ import im.vector.app.features.command.ParsedCommand import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.room.detail.AutoCompleter import im.vector.app.features.home.room.detail.RoomDetailAction +import im.vector.app.features.home.room.detail.RoomDetailAction.VoiceBroadcastAction import im.vector.app.features.home.room.detail.TimelineViewModel import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView import im.vector.app.features.home.room.detail.timeline.action.MessageSharedActionViewModel @@ -102,7 +104,6 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageFormat import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent -import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent import org.matrix.android.sdk.api.util.MatrixItem import org.matrix.android.sdk.api.util.toMatrixItem import reactivecircus.flowbinding.android.view.focusChanges @@ -355,7 +356,7 @@ class MessageComposerFragment : VectorBaseFragment(), A setTextColor(matrixItemColorProvider.getColor(MatrixItem.UserItem(event.root.senderId ?: "@"))) } - val messageContent: MessageContent? = event.getLastMessageContent() + val messageContent: MessageContent? = event.getVectorLastMessageContent() val nonFormattedBody = when (messageContent) { is MessageAudioContent -> getAudioContentBodyText(messageContent) is MessagePollContent -> messageContent.getBestPollCreationInfo()?.question?.getBestQuestion() @@ -653,7 +654,7 @@ class MessageComposerFragment : VectorBaseFragment(), A locationOwnerId = session.myUserId ) } - AttachmentTypeSelectorView.Type.VOICE_BROADCAST -> timelineViewModel.handle(RoomDetailAction.StartVoiceBroadcast) + AttachmentTypeSelectorView.Type.VOICE_BROADCAST -> timelineViewModel.handle(VoiceBroadcastAction.Start) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt index afdd01ba46..c83f818ac8 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt @@ -23,6 +23,7 @@ import dagger.assisted.AssistedInject import im.vector.app.R import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory +import im.vector.app.core.extensions.getVectorLastMessageContent import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.resources.StringProvider import im.vector.app.features.analytics.AnalyticsTracker @@ -62,7 +63,6 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.api.session.room.model.relation.shouldRenderInThread import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper import org.matrix.android.sdk.api.session.room.send.UserDraft -import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent import org.matrix.android.sdk.api.session.room.timeline.getRelationContent import org.matrix.android.sdk.api.session.room.timeline.getTextEditableContent import org.matrix.android.sdk.api.session.space.CreateSpaceParams @@ -513,7 +513,7 @@ class MessageComposerViewModel @AssistedInject constructor( room.relationService().editReply(state.sendMode.timelineEvent, it, action.text.toString()) } } else { - val messageContent = state.sendMode.timelineEvent.getLastMessageContent() + val messageContent = state.sendMode.timelineEvent.getVectorLastMessageContent() val existingBody = messageContent?.body ?: "" if (existingBody != action.text) { room.relationService().editTextMessage( diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanRedactEventUseCase.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanRedactEventUseCase.kt index 3bc3a5e351..eda1929133 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanRedactEventUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanRedactEventUseCase.kt @@ -17,6 +17,7 @@ package im.vector.app.features.home.room.detail.timeline.action import im.vector.app.core.di.ActiveSessionHolder +import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import javax.inject.Inject @@ -27,8 +28,13 @@ class CheckIfCanRedactEventUseCase @Inject constructor( fun execute(event: TimelineEvent, actionPermissions: ActionPermissions): Boolean { // Only some event types are supported for the moment - val canRedactEventTypes = listOf(EventType.MESSAGE, EventType.STICKER) + - EventType.POLL_START + EventType.STATE_ROOM_BEACON_INFO + val canRedactEventTypes: List = listOf( + EventType.MESSAGE, + EventType.STICKER, + STATE_ROOM_VOICE_BROADCAST_INFO, + ) + + EventType.POLL_START + + EventType.STATE_ROOM_BEACON_INFO return event.root.getClearType() in canRedactEventTypes && // Message sent by the current user can always be redacted, else check permission for messages sent by other users 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 3dfb6744e0..0c44ee386d 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 @@ -25,6 +25,7 @@ import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.core.error.ErrorFormatter import im.vector.app.core.extensions.canReact +import im.vector.app.core.extensions.getVectorLastMessageContent import im.vector.app.core.platform.EmptyViewEvents import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.resources.StringProvider @@ -60,7 +61,6 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageWithAttachme import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent -import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent import org.matrix.android.sdk.api.session.room.timeline.hasBeenEdited import org.matrix.android.sdk.api.session.room.timeline.isPoll import org.matrix.android.sdk.api.session.room.timeline.isRootThread @@ -187,7 +187,7 @@ class MessageActionsViewModel @AssistedInject constructor( when (timelineEvent.root.getClearType()) { EventType.MESSAGE, EventType.STICKER -> { - val messageContent: MessageContent? = timelineEvent.getLastMessageContent() + val messageContent: MessageContent? = timelineEvent.getVectorLastMessageContent() if (messageContent is MessageTextContent && messageContent.format == MessageFormat.FORMAT_MATRIX_HTML) { val html = messageContent.formattedBody ?.takeIf { it.isNotBlank() } @@ -253,7 +253,7 @@ class MessageActionsViewModel @AssistedInject constructor( } private fun actionsForEvent(timelineEvent: TimelineEvent, actionPermissions: ActionPermissions): List { - val messageContent = timelineEvent.getLastMessageContent() + val messageContent = timelineEvent.getVectorLastMessageContent() val msgType = messageContent?.msgType return arrayListOf().apply { 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 eb7b0d4ed8..e7833c2824 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 @@ -28,6 +28,7 @@ import dagger.Lazy import im.vector.app.R import im.vector.app.core.epoxy.ClickListener import im.vector.app.core.epoxy.VectorEpoxyModel +import im.vector.app.core.extensions.getVectorLastMessageContent import im.vector.app.core.files.LocalFilesHelper import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.StringProvider @@ -55,6 +56,8 @@ import im.vector.app.features.home.room.detail.timeline.item.MessageLocationItem import im.vector.app.features.home.room.detail.timeline.item.MessageLocationItem_ import im.vector.app.features.home.room.detail.timeline.item.MessageTextItem import im.vector.app.features.home.room.detail.timeline.item.MessageTextItem_ +import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceBroadcastItem +import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceBroadcastItem_ import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceItem import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceItem_ import im.vector.app.features.home.room.detail.timeline.item.PollItem @@ -77,6 +80,7 @@ import im.vector.app.features.media.ImageContentRenderer import im.vector.app.features.media.VideoContentRenderer import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.voice.AudioWaveformView +import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence import me.gujun.android.span.span import org.matrix.android.sdk.api.MatrixUrls.isMxcUrl @@ -102,7 +106,6 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageVerification import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent import org.matrix.android.sdk.api.session.room.model.message.getFileUrl import org.matrix.android.sdk.api.session.room.model.message.getThumbnailUrl -import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent import org.matrix.android.sdk.api.settings.LightweightSettingsStorage import org.matrix.android.sdk.api.util.MimeTypes import javax.inject.Inject @@ -163,7 +166,7 @@ class MessageItemFactory @Inject constructor( return buildRedactedItem(attributes, highlight) } - val messageContent = event.getLastMessageContent() + val messageContent = event.getVectorLastMessageContent() if (messageContent == null) { val malformedText = stringProvider.getString(R.string.malformed_message) return defaultItemFactory.create(malformedText, informationData, highlight, callback) @@ -197,6 +200,7 @@ class MessageItemFactory @Inject constructor( is MessagePollContent -> buildPollItem(messageContent, informationData, highlight, callback, attributes) is MessageLocationContent -> buildLocationItem(messageContent, informationData, highlight, attributes) is MessageBeaconInfoContent -> liveLocationShareMessageItemFactory.create(params.event, highlight, attributes) + is MessageVoiceBroadcastInfoContent -> buildVoiceBroadcastItem(messageContent, highlight, callback, attributes) else -> buildNotHandledMessageItem(messageContent, informationData, highlight, callback, attributes) } return messageItem?.apply { @@ -706,6 +710,20 @@ class MessageItemFactory @Inject constructor( .highlighted(highlight) } + private fun buildVoiceBroadcastItem( + messageContent: MessageVoiceBroadcastInfoContent, + highlight: Boolean, + callback: TimelineEventController.Callback?, + attributes: AbsMessageItem.Attributes, + ): MessageVoiceBroadcastItem? { + return MessageVoiceBroadcastItem_() + .attributes(attributes) + .highlighted(highlight) + .voiceBroadcastState(messageContent.voiceBroadcastState) + .leftGuideline(avatarSizeProvider.leftGuideline) + .callback(callback) + } + private fun List?.toFft(): List? { return this ?.filterNotNull() 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 6c5a66d39d..0b8f95b4a1 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 @@ -21,6 +21,7 @@ import im.vector.app.core.epoxy.TimelineEmptyItem_ import im.vector.app.core.epoxy.VectorEpoxyModel import im.vector.app.features.analytics.DecryptionFailureTracker import im.vector.app.features.home.room.detail.timeline.helper.TimelineEventVisibilityHelper +import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import timber.log.Timber @@ -88,6 +89,7 @@ class TimelineItemFactory @Inject constructor( // State room create EventType.STATE_ROOM_CREATE -> roomCreateItemFactory.create(params) in EventType.STATE_ROOM_BEACON_INFO -> messageItemFactory.create(params) + STATE_ROOM_VOICE_BROADCAST_INFO -> messageItemFactory.create(params) // Unhandled state event types else -> { // Should only happen when shouldShowHiddenEvents() settings is ON 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 7b9bd4530b..eb531b6f1b 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 @@ -19,6 +19,7 @@ package im.vector.app.features.home.room.detail.timeline.format import dagger.Lazy import im.vector.app.EmojiSpanify import im.vector.app.R +import im.vector.app.core.extensions.getVectorLastMessageContent import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.StringProvider import im.vector.app.features.html.EventHtmlRenderer @@ -34,7 +35,6 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.api.session.room.model.relation.ReactionContent import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent -import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent import org.matrix.android.sdk.api.session.room.timeline.getTextDisplayableContent import javax.inject.Inject @@ -60,7 +60,7 @@ class DisplayableEventFormatter @Inject constructor( return when (timelineEvent.root.getClearType()) { EventType.MESSAGE -> { - timelineEvent.getLastMessageContent()?.let { messageContent -> + timelineEvent.getVectorLastMessageContent()?.let { messageContent -> when (messageContent.msgType) { MessageType.MSGTYPE_TEXT -> { val body = messageContent.getTextDisplayableContent() 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 ddb98c42c6..50b4366e98 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 @@ -18,6 +18,7 @@ package im.vector.app.features.home.room.detail.timeline.helper import im.vector.app.core.date.DateFormatKind import im.vector.app.core.date.VectorDateFormatter +import im.vector.app.core.extensions.getVectorLastMessageContent import im.vector.app.core.extensions.localDateTime import im.vector.app.features.home.room.detail.timeline.factory.TimelineItemFactoryParams import im.vector.app.features.home.room.detail.timeline.item.E2EDecoration @@ -41,7 +42,6 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.api.session.room.model.message.MessageVerificationRequestContent import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent -import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent import org.matrix.android.sdk.api.session.room.timeline.hasBeenEdited import javax.inject.Inject @@ -123,7 +123,11 @@ class MessageInformationDataFactory @Inject constructor( isLastFromThisSender = isLastFromThisSender, e2eDecoration = e2eDecoration, sendStateDecoration = sendStateDecoration, - messageType = if (event.root.isSticker()) { MessageType.MSGTYPE_STICKER_LOCAL } else { event.root.getMsgType() } + messageType = if (event.root.isSticker()) { + MessageType.MSGTYPE_STICKER_LOCAL + } else { + event.root.getMsgType() + } ) } @@ -230,7 +234,7 @@ class MessageInformationDataFactory @Inject constructor( EventType.KEY_VERIFICATION_DONE, EventType.KEY_VERIFICATION_CANCEL -> true EventType.MESSAGE -> { - event.getLastMessageContent() is MessageVerificationRequestContent + event.getVectorLastMessageContent() is MessageVerificationRequestContent } else -> false } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt index 23db2a721c..87844aba8e 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt @@ -16,6 +16,7 @@ package im.vector.app.features.home.room.detail.timeline.helper +import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent @@ -24,7 +25,7 @@ object TimelineDisplayableEvents { /** * All types we have an item to build with. Every type not defined here will be shown as DefaultItem if forced to be shown, otherwise will be hidden. */ - val DISPLAYABLE_TYPES = listOf( + val DISPLAYABLE_TYPES: List = listOf( EventType.MESSAGE, EventType.STATE_ROOM_WIDGET_LEGACY, EventType.STATE_ROOM_WIDGET, @@ -51,7 +52,11 @@ object TimelineDisplayableEvents { EventType.STATE_ROOM_JOIN_RULES, EventType.KEY_VERIFICATION_DONE, EventType.KEY_VERIFICATION_CANCEL, - ) + EventType.POLL_START + EventType.STATE_ROOM_BEACON_INFO + EventType.BEACON_LOCATION_DATA + STATE_ROOM_VOICE_BROADCAST_INFO, + ) + + EventType.POLL_START + + EventType.STATE_ROOM_BEACON_INFO + + EventType.BEACON_LOCATION_DATA } fun TimelineEvent.isRoomConfiguration(roomCreatorUserId: String?): Boolean { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastItem.kt new file mode 100644 index 0000000000..bd6a5b7bdb --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastItem.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail.timeline.item + +import android.annotation.SuppressLint +import android.widget.ImageButton +import android.widget.TextView +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.app.R +import im.vector.app.features.home.room.detail.RoomDetailAction +import im.vector.app.features.home.room.detail.timeline.TimelineEventController +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState + +@EpoxyModelClass +abstract class MessageVoiceBroadcastItem : AbsMessageItem() { + + @EpoxyAttribute + var callback: TimelineEventController.Callback? = null + + @EpoxyAttribute + var voiceBroadcastState: VoiceBroadcastState? = null + + override fun bind(holder: Holder) { + super.bind(holder) + bindVoiceBroadcastItem(holder) + } + + @SuppressLint("SetTextI18n") // Temporary text + private fun bindVoiceBroadcastItem(holder: Holder) { + with(holder) { + currentStateText.text = "Voice Broadcast state: ${voiceBroadcastState?.value ?: "None"}" + playButton.isEnabled = voiceBroadcastState == VoiceBroadcastState.PAUSED + pauseButton.isEnabled = voiceBroadcastState == VoiceBroadcastState.STARTED || voiceBroadcastState == VoiceBroadcastState.RESUMED + stopButton.isEnabled = voiceBroadcastState == VoiceBroadcastState.STARTED || + voiceBroadcastState == VoiceBroadcastState.RESUMED || + voiceBroadcastState == VoiceBroadcastState.PAUSED + playButton.setOnClickListener { attributes.callback?.onTimelineItemAction(RoomDetailAction.VoiceBroadcastAction.Resume) } + pauseButton.setOnClickListener { attributes.callback?.onTimelineItemAction(RoomDetailAction.VoiceBroadcastAction.Pause) } + stopButton.setOnClickListener { attributes.callback?.onTimelineItemAction(RoomDetailAction.VoiceBroadcastAction.Stop) } + } + } + + override fun getViewStubId() = STUB_ID + + class Holder : AbsMessageLocationItem.Holder(STUB_ID) { + val currentStateText by bind(R.id.currentStateText) + val playButton by bind(R.id.playButton) + val pauseButton by bind(R.id.pauseButton) + val stopButton by bind(R.id.stopButton) + } + + companion object { + private val STUB_ID = R.id.messageVoiceBroadcastStub + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayoutFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayoutFactory.kt index 14a02c7172..379e5b3b91 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayoutFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayoutFactory.kt @@ -18,6 +18,7 @@ package im.vector.app.features.home.room.detail.timeline.style import android.content.res.Resources import im.vector.app.R +import im.vector.app.core.extensions.getVectorLastMessageContent import im.vector.app.core.extensions.localDateTime import im.vector.app.core.resources.LocaleProvider import im.vector.app.core.resources.isRTL @@ -29,7 +30,6 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageContent import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.api.session.room.model.message.MessageVerificationRequestContent import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent -import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent import org.matrix.android.sdk.api.session.room.timeline.isEdition import org.matrix.android.sdk.api.session.room.timeline.isRootThread import javax.inject.Inject @@ -126,7 +126,7 @@ class TimelineMessageLayoutFactory @Inject constructor( isLastFromThisSender = isLastFromThisSender ) - val messageContent = event.getLastMessageContent() + val messageContent = event.getVectorLastMessageContent() TimelineMessageLayout.Bubble( showAvatar = showInformation && !isSentByMe, showDisplayName = showInformation && !isSentByMe, @@ -167,7 +167,7 @@ class TimelineMessageLayoutFactory @Inject constructor( private fun TimelineEvent.shouldBuildBubbleLayout(): Boolean { val type = root.getClearType() if (type in EVENT_TYPES_WITH_BUBBLE_LAYOUT) { - val messageContent = getLastMessageContent() + val messageContent = getVectorLastMessageContent() return messageContent?.msgType !in MSG_TYPES_WITHOUT_BUBBLE_LAYOUT } return false @@ -212,7 +212,7 @@ class TimelineMessageLayoutFactory @Inject constructor( EventType.KEY_VERIFICATION_DONE, EventType.KEY_VERIFICATION_CANCEL -> true EventType.MESSAGE -> { - event.getLastMessageContent() is MessageVerificationRequestContent + event.getVectorLastMessageContent() is MessageVerificationRequestContent } else -> false } 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 90138fd495..4ee7da4b64 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 @@ -17,6 +17,7 @@ package im.vector.app.features.notifications import android.net.Uri import im.vector.app.R +import im.vector.app.core.extensions.getVectorLastMessageContent import im.vector.app.core.extensions.takeAs import im.vector.app.core.resources.BuildMeta import im.vector.app.core.resources.StringProvider @@ -45,7 +46,6 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageWithAttachme import org.matrix.android.sdk.api.session.room.sender.SenderInfo import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.getEditedEventId -import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent import org.matrix.android.sdk.api.util.toMatrixItem import timber.log.Timber import java.util.UUID @@ -231,7 +231,7 @@ class NotifiableEventResolver @Inject constructor( private suspend fun TimelineEvent.downloadAndExportImage(session: Session): Uri? { return kotlin.runCatching { - getLastMessageContent()?.takeAs()?.let { imageMessage -> + getVectorLastMessageContent()?.takeAs()?.let { imageMessage -> val fileService = session.fileService() fileService.downloadFile(imageMessage) fileService.getTemporarySharableURI(imageMessage) diff --git a/vector/src/main/java/im/vector/app/features/poll/create/CreatePollViewModel.kt b/vector/src/main/java/im/vector/app/features/poll/create/CreatePollViewModel.kt index ec064877a9..1b2f0d7d08 100644 --- a/vector/src/main/java/im/vector/app/features/poll/create/CreatePollViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/poll/create/CreatePollViewModel.kt @@ -22,6 +22,7 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory +import im.vector.app.core.extensions.getVectorLastMessageContent import im.vector.app.core.platform.VectorViewModel import im.vector.app.features.poll.PollMode import org.matrix.android.sdk.api.session.Session @@ -29,7 +30,6 @@ import org.matrix.android.sdk.api.session.getRoom import org.matrix.android.sdk.api.session.room.getTimelineEvent import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent import org.matrix.android.sdk.api.session.room.model.message.PollType -import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent class CreatePollViewModel @AssistedInject constructor( @Assisted private val initialState: CreatePollViewState, @@ -72,7 +72,7 @@ class CreatePollViewModel @AssistedInject constructor( private fun initializeEditedPoll(eventId: String) { val event = room.getTimelineEvent(eventId) ?: return - val content = event.getLastMessageContent() as? MessagePollContent ?: return + val content = event.getVectorLastMessageContent() as? MessagePollContent ?: return val pollCreationInfo = content.getBestPollCreationInfo() val pollType = pollCreationInfo?.kind ?: PollType.DISCLOSED_UNSTABLE diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastConstants.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastConstants.kt new file mode 100644 index 0000000000..d7d74b08e9 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastConstants.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.voicebroadcast + +/** Voice Broadcast State Event. */ +const val STATE_ROOM_VOICE_BROADCAST_INFO = "io.element.voice_broadcast_info" diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastHelper.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastHelper.kt new file mode 100644 index 0000000000..f682cd2f5e --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastHelper.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2021 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.voicebroadcast + +import im.vector.app.features.voicebroadcast.usecase.PauseVoiceBroadcastUseCase +import im.vector.app.features.voicebroadcast.usecase.ResumeVoiceBroadcastUseCase +import im.vector.app.features.voicebroadcast.usecase.StartVoiceBroadcastUseCase +import im.vector.app.features.voicebroadcast.usecase.StopVoiceBroadcastUseCase +import javax.inject.Inject + +/** + * Helper class to record voice broadcast. + */ +class VoiceBroadcastHelper @Inject constructor( + private val startVoiceBroadcastUseCase: StartVoiceBroadcastUseCase, + private val pauseVoiceBroadcastUseCase: PauseVoiceBroadcastUseCase, + private val resumeVoiceBroadcastUseCase: ResumeVoiceBroadcastUseCase, + private val stopVoiceBroadcastUseCase: StopVoiceBroadcastUseCase, +) { + suspend fun startVoiceBroadcast(roomId: String) = startVoiceBroadcastUseCase.execute(roomId) + + suspend fun pauseVoiceBroadcast(roomId: String) = pauseVoiceBroadcastUseCase.execute(roomId) + + suspend fun resumeVoiceBroadcast(roomId: String) = resumeVoiceBroadcastUseCase.execute(roomId) + + suspend fun stopVoiceBroadcast(roomId: String) = stopVoiceBroadcastUseCase.execute(roomId) +} diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/model/MessageVoiceBroadcastInfoContent.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/model/MessageVoiceBroadcastInfoContent.kt new file mode 100644 index 0000000000..b33d6cc4da --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/model/MessageVoiceBroadcastInfoContent.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.voicebroadcast.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO +import org.matrix.android.sdk.api.session.events.model.Content +import org.matrix.android.sdk.api.session.room.model.message.MessageContent +import org.matrix.android.sdk.api.session.room.model.message.MessageType.MSGTYPE_VOICE_BROADCAST_INFO +import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent +import timber.log.Timber + +/** + * Content of the state event of type [STATE_ROOM_VOICE_BROADCAST_INFO]. + * + * It contains general info related to a voice broadcast. + */ +@JsonClass(generateAdapter = true) +data class MessageVoiceBroadcastInfoContent( + /** Local message type, not from server. */ + @Transient override val msgType: String = MSGTYPE_VOICE_BROADCAST_INFO, + @Json(name = "body") override val body: String = "", + @Json(name = "m.relates_to") override val relatesTo: RelationDefaultContent? = null, + @Json(name = "m.new_content") override val newContent: Content? = null, + + /** The [VoiceBroadcastState] value. **/ + @Json(name = "state") val voiceBroadcastStateStr: String = "", + /** The length of the voice chunks in seconds. **/ + @Json(name = "chunk_length") val chunkLength: Long? = null, +) : MessageContent { + + val voiceBroadcastState: VoiceBroadcastState? = VoiceBroadcastState.values() + .find { it.value == voiceBroadcastStateStr } + ?: run { + Timber.w("Invalid value for state: `$voiceBroadcastStateStr`") + null + } +} diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/model/VoiceBroadcastEvent.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/model/VoiceBroadcastEvent.kt new file mode 100644 index 0000000000..c09a5712a8 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/model/VoiceBroadcastEvent.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.voicebroadcast.model + +import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.RelationType +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent + +/** + * [Event] wrapper for [STATE_ROOM_VOICE_BROADCAST_INFO] event type. + * Provides additional fields and functions related to voice broadcast. + */ +@JvmInline +value class VoiceBroadcastEvent(val root: Event) { + + /** + * Reference on the initial voice broadcast state event (ie. with [MessageVoiceBroadcastInfoContent.voiceBroadcastState]=[VoiceBroadcastState.STARTED]). + */ + val reference: RelationDefaultContent? + get() { + val voiceBroadcastInfoContent = root.content.toModel() + return if (voiceBroadcastInfoContent?.voiceBroadcastState == VoiceBroadcastState.STARTED) { + RelationDefaultContent(RelationType.REFERENCE, root.eventId) + } else { + voiceBroadcastInfoContent?.relatesTo + } + } + + /** + * The mapped [MessageVoiceBroadcastInfoContent] model of the event content. + */ + val content: MessageVoiceBroadcastInfoContent? + get() = root.content.toModel() +} + +/** + * Map a [STATE_ROOM_VOICE_BROADCAST_INFO] state event to a [VoiceBroadcastEvent]. + */ +fun Event.asVoiceBroadcastEvent() = if (type == STATE_ROOM_VOICE_BROADCAST_INFO) VoiceBroadcastEvent(this) else null diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/model/VoiceBroadcastState.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/model/VoiceBroadcastState.kt new file mode 100644 index 0000000000..02e1b2decc --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/model/VoiceBroadcastState.kt @@ -0,0 +1,46 @@ +/* + * 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 im.vector.app.features.voicebroadcast.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Ref: https://github.com/vector-im/element-meta/discussions/632 + */ +@JsonClass(generateAdapter = false) +enum class VoiceBroadcastState(val value: String) { + /** + * The voice broadcast had been started and is currently being live. + */ + @Json(name = "started") STARTED("started"), + + /** + * The voice broadcast has been paused and may be resumed at any time by the recorder. + */ + @Json(name = "paused") PAUSED("paused"), + + /** + * The voice broadcast is currently being live again. + */ + @Json(name = "resumed") RESUMED("resumed"), + + /** + * The voice broadcast has ended. + */ + @Json(name = "stopped") STOPPED("stopped"), +} diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/PauseVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/PauseVoiceBroadcastUseCase.kt new file mode 100644 index 0000000000..8f61284423 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/PauseVoiceBroadcastUseCase.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.voicebroadcast.usecase + +import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO +import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import org.matrix.android.sdk.api.query.QueryStringValue +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.events.model.toContent +import org.matrix.android.sdk.api.session.getRoom +import org.matrix.android.sdk.api.session.room.Room +import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent +import timber.log.Timber +import javax.inject.Inject + +class PauseVoiceBroadcastUseCase @Inject constructor( + private val session: Session, +) { + + suspend fun execute(roomId: String): Result = runCatching { + val room = session.getRoom(roomId) ?: error("Unknown roomId: $roomId") + + Timber.d("## PauseVoiceBroadcastUseCase: Pause voice broadcast requested") + + val lastVoiceBroadcastEvent = room.stateService().getStateEvent( + STATE_ROOM_VOICE_BROADCAST_INFO, + QueryStringValue.Equals(session.myUserId) + )?.asVoiceBroadcastEvent() + when (val voiceBroadcastState = lastVoiceBroadcastEvent?.content?.voiceBroadcastState) { + VoiceBroadcastState.STARTED, + VoiceBroadcastState.RESUMED -> pauseVoiceBroadcast(room, lastVoiceBroadcastEvent.reference) + else -> Timber.d("## PauseVoiceBroadcastUseCase: Cannot pause voice broadcast: currentState=$voiceBroadcastState") + } + } + + private suspend fun pauseVoiceBroadcast(room: Room, reference: RelationDefaultContent?) { + Timber.d("## PauseVoiceBroadcastUseCase: Send new voice broadcast info state event") + room.stateService().sendStateEvent( + eventType = STATE_ROOM_VOICE_BROADCAST_INFO, + stateKey = session.myUserId, + body = MessageVoiceBroadcastInfoContent( + relatesTo = reference, + voiceBroadcastStateStr = VoiceBroadcastState.PAUSED.value, + ).toContent(), + ) + + // TODO pause recording audio files + } +} diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/ResumeVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/ResumeVoiceBroadcastUseCase.kt new file mode 100644 index 0000000000..d0d82b42c3 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/ResumeVoiceBroadcastUseCase.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.voicebroadcast.usecase + +import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO +import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import org.matrix.android.sdk.api.query.QueryStringValue +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.events.model.toContent +import org.matrix.android.sdk.api.session.getRoom +import org.matrix.android.sdk.api.session.room.Room +import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent +import timber.log.Timber +import javax.inject.Inject + +class ResumeVoiceBroadcastUseCase @Inject constructor( + private val session: Session, +) { + + suspend fun execute(roomId: String): Result = runCatching { + val room = session.getRoom(roomId) ?: error("Unknown roomId: $roomId") + + Timber.d("## ResumeVoiceBroadcastUseCase: Resume voice broadcast requested") + + val lastVoiceBroadcastEvent = room.stateService().getStateEvent( + STATE_ROOM_VOICE_BROADCAST_INFO, + QueryStringValue.Equals(session.myUserId) + )?.asVoiceBroadcastEvent() + when (val voiceBroadcastState = lastVoiceBroadcastEvent?.content?.voiceBroadcastState) { + VoiceBroadcastState.PAUSED -> resumeVoiceBroadcast(room, lastVoiceBroadcastEvent.reference) + else -> Timber.d("## ResumeVoiceBroadcastUseCase: Cannot resume voice broadcast: currentState=$voiceBroadcastState") + } + } + + /** + * Resume a paused voice broadcast in the given room. + * + * @param room the room related to the voice broadcast + * @param reference reference on the initial voice broadcast state event (ie. state=STARTED) + */ + private suspend fun resumeVoiceBroadcast(room: Room, reference: RelationDefaultContent?) { + Timber.d("## ResumeVoiceBroadcastUseCase: Send new voice broadcast info state event") + room.stateService().sendStateEvent( + eventType = STATE_ROOM_VOICE_BROADCAST_INFO, + stateKey = session.myUserId, + body = MessageVoiceBroadcastInfoContent( + relatesTo = reference, + voiceBroadcastStateStr = VoiceBroadcastState.RESUMED.value, + ).toContent(), + ) + + // TODO resume recording audio files + } +} diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCase.kt new file mode 100644 index 0000000000..0b8328cd4b --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCase.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.voicebroadcast.usecase + +import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO +import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import org.matrix.android.sdk.api.query.QueryStringValue +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.events.model.toContent +import org.matrix.android.sdk.api.session.getRoom +import org.matrix.android.sdk.api.session.room.Room +import timber.log.Timber +import javax.inject.Inject + +class StartVoiceBroadcastUseCase @Inject constructor( + private val session: Session, +) { + + suspend fun execute(roomId: String): Result = runCatching { + val room = session.getRoom(roomId) ?: error("Unknown roomId: $roomId") + + Timber.d("## StartVoiceBroadcastUseCase: Start voice broadcast requested") + + val onGoingVoiceBroadcastEvents = room.stateService().getStateEvents( + setOf(STATE_ROOM_VOICE_BROADCAST_INFO), + QueryStringValue.IsNotEmpty + ) + .mapNotNull { it.asVoiceBroadcastEvent() } + .filter { it.content?.voiceBroadcastState != VoiceBroadcastState.STOPPED } + + if (onGoingVoiceBroadcastEvents.isEmpty()) { + startVoiceBroadcast(room) + } else { + Timber.d("## StartVoiceBroadcastUseCase: Cannot start voice broadcast: currentVoiceBroadcastEvents=$onGoingVoiceBroadcastEvents") + } + } + + private suspend fun startVoiceBroadcast(room: Room) { + Timber.d("## StartVoiceBroadcastUseCase: Send new voice broadcast info state event") + room.stateService().sendStateEvent( + eventType = STATE_ROOM_VOICE_BROADCAST_INFO, + stateKey = session.myUserId, + body = MessageVoiceBroadcastInfoContent( + voiceBroadcastStateStr = VoiceBroadcastState.STARTED.value, + chunkLength = 5L, // TODO Get length from voice broadcast settings + ).toContent() + ) + + // TODO start recording audio files + } +} diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StopVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StopVoiceBroadcastUseCase.kt new file mode 100644 index 0000000000..8b22193770 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StopVoiceBroadcastUseCase.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.voicebroadcast.usecase + +import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO +import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import org.matrix.android.sdk.api.query.QueryStringValue +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.events.model.toContent +import org.matrix.android.sdk.api.session.getRoom +import org.matrix.android.sdk.api.session.room.Room +import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent +import timber.log.Timber +import javax.inject.Inject + +class StopVoiceBroadcastUseCase @Inject constructor( + private val session: Session, +) { + + suspend fun execute(roomId: String): Result = runCatching { + val room = session.getRoom(roomId) ?: error("Unknown roomId: $roomId") + + Timber.d("## StopVoiceBroadcastUseCase: Stop voice broadcast requested") + + val lastVoiceBroadcastEvent = room.stateService().getStateEvent( + STATE_ROOM_VOICE_BROADCAST_INFO, + QueryStringValue.Equals(session.myUserId) + )?.asVoiceBroadcastEvent() + when (val voiceBroadcastState = lastVoiceBroadcastEvent?.content?.voiceBroadcastState) { + VoiceBroadcastState.STARTED, + VoiceBroadcastState.PAUSED, + VoiceBroadcastState.RESUMED -> stopVoiceBroadcast(room, lastVoiceBroadcastEvent.reference) + else -> Timber.d("## StopVoiceBroadcastUseCase: Cannot stop voice broadcast: currentState=$voiceBroadcastState") + } + } + + private suspend fun stopVoiceBroadcast(room: Room, reference: RelationDefaultContent?) { + Timber.d("## StopVoiceBroadcastUseCase: Send new voice broadcast info state event") + room.stateService().sendStateEvent( + eventType = STATE_ROOM_VOICE_BROADCAST_INFO, + stateKey = session.myUserId, + body = MessageVoiceBroadcastInfoContent( + relatesTo = reference, + voiceBroadcastStateStr = VoiceBroadcastState.STOPPED.value, + ).toContent(), + ) + + // TODO stop recording audio files + } +} diff --git a/vector/src/main/res/layout/item_timeline_event_view_stubs_container.xml b/vector/src/main/res/layout/item_timeline_event_view_stubs_container.xml index 0d45a48b9b..6fcf5711f7 100644 --- a/vector/src/main/res/layout/item_timeline_event_view_stubs_container.xml +++ b/vector/src/main/res/layout/item_timeline_event_view_stubs_container.xml @@ -47,6 +47,13 @@ android:layout="@layout/item_timeline_event_audio_stub" tools:visibility="gone" /> + + + + + + + + + + + + + diff --git a/vector/src/test/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanRedactEventUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanRedactEventUseCaseTest.kt index 08dd5dac5b..fcb052cb2b 100644 --- a/vector/src/test/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanRedactEventUseCaseTest.kt +++ b/vector/src/test/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanRedactEventUseCaseTest.kt @@ -16,6 +16,7 @@ package im.vector.app.features.home.room.detail.timeline.action +import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO import im.vector.app.test.fakes.FakeActiveSessionHolder import io.mockk.mockk import org.amshove.kluent.shouldBe @@ -34,7 +35,7 @@ class CheckIfCanRedactEventUseCaseTest { @Test fun `given an event which can be redacted and owned by user when use case executes then the result is true`() { - val canRedactEventTypes = listOf(EventType.MESSAGE, EventType.STICKER) + + val canRedactEventTypes = listOf(EventType.MESSAGE, EventType.STICKER, STATE_ROOM_VOICE_BROADCAST_INFO) + EventType.POLL_START + EventType.STATE_ROOM_BEACON_INFO canRedactEventTypes.forEach { eventType -> diff --git a/vector/src/test/java/im/vector/app/features/voicebroadcast/model/VoiceBroadcastEventTest.kt b/vector/src/test/java/im/vector/app/features/voicebroadcast/model/VoiceBroadcastEventTest.kt new file mode 100644 index 0000000000..8865e870f0 --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/voicebroadcast/model/VoiceBroadcastEventTest.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.voicebroadcast.model + +import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeNull +import org.amshove.kluent.shouldNotBeNull +import org.junit.Test +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.RelationType +import org.matrix.android.sdk.api.session.events.model.toContent +import org.matrix.android.sdk.api.session.room.model.message.AudioInfo +import org.matrix.android.sdk.api.session.room.model.message.AudioWaveformInfo +import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent +import org.matrix.android.sdk.api.session.room.model.message.MessageType +import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent +import org.matrix.android.sdk.api.session.room.model.relation.ReplyToContent + +private const val AN_EVENT_ID = "event_id" +private const val A_REFERENCED_EVENT_ID = "event_id_ref" +private const val A_CHUNK_LENGTH = 3_600L + +class VoiceBroadcastEventTest { + + @Test + fun `given a started Voice Broadcast Event, when mapping to VoiceBroadcastEvent, then return expected object`() { + // Given + val content = MessageVoiceBroadcastInfoContent( + voiceBroadcastStateStr = VoiceBroadcastState.STARTED.value, + chunkLength = A_CHUNK_LENGTH, + relatesTo = RelationDefaultContent(RelationType.REFERENCE, A_REFERENCED_EVENT_ID), + ) + val event = Event( + eventId = AN_EVENT_ID, + type = STATE_ROOM_VOICE_BROADCAST_INFO, + content = content.toContent(), + ) + val expectedReference = RelationDefaultContent(RelationType.REFERENCE, event.eventId) + + // When + val result = event.asVoiceBroadcastEvent() + + // Then + result.shouldNotBeNull() + result.content shouldBeEqualTo content + result.reference shouldBeEqualTo expectedReference + } + + @Test + fun `given a not started Voice Broadcast Event, when mapping to VoiceBroadcastEvent, then return expected object`() { + // Given + val content = MessageVoiceBroadcastInfoContent( + voiceBroadcastStateStr = VoiceBroadcastState.PAUSED.value, + chunkLength = A_CHUNK_LENGTH, + relatesTo = RelationDefaultContent(RelationType.REFERENCE, A_REFERENCED_EVENT_ID), + ) + val event = Event( + type = STATE_ROOM_VOICE_BROADCAST_INFO, + content = content.toContent(), + ) + val expectedReference = content.relatesTo + + // When + val result = event.asVoiceBroadcastEvent() + + // Then + result.shouldNotBeNull() + result.content shouldBeEqualTo content + result.reference shouldBeEqualTo expectedReference + } + + @Test + fun `given a non Voice Broadcast Event, when mapping to VoiceBroadcastEvent, then return null`() { + // Given + val content = MessageAudioContent( + msgType = MessageType.MSGTYPE_AUDIO, + body = "audio", + audioInfo = AudioInfo( + duration = 300, + mimeType = "", + size = 500L + ), + url = "a_url", + audioWaveformInfo = AudioWaveformInfo( + duration = 300, + waveform = null + ), + voiceMessageIndicator = emptyMap(), + relatesTo = RelationDefaultContent( + type = RelationType.THREAD, + eventId = AN_EVENT_ID, + isFallingBack = true, + inReplyTo = ReplyToContent(eventId = A_REFERENCED_EVENT_ID) + ) + ) + val event = Event( + type = EventType.MESSAGE, + content = content.toContent(), + ) + + // When + val result = event.asVoiceBroadcastEvent() + + // Then + result.shouldBeNull() + } +} diff --git a/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/PauseVoiceBroadcastUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/PauseVoiceBroadcastUseCaseTest.kt new file mode 100644 index 0000000000..3139f20cd4 --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/PauseVoiceBroadcastUseCaseTest.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.voicebroadcast.usecase + +import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO +import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.test.fakes.FakeRoom +import im.vector.app.test.fakes.FakeRoomService +import im.vector.app.test.fakes.FakeSession +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.slot +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBe +import org.junit.Test +import org.matrix.android.sdk.api.session.events.model.Content +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.RelationType +import org.matrix.android.sdk.api.session.events.model.toContent +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent + +private const val A_ROOM_ID = "room_id" +private const val AN_EVENT_ID = "event_id" +private const val A_STARTED_VOICE_BROADCAST_EVENT_ID = "a_started_voice_broadcast_event_id" + +class PauseVoiceBroadcastUseCaseTest { + + private val fakeRoom = FakeRoom() + private val fakeSession = FakeSession(fakeRoomService = FakeRoomService(fakeRoom)) + private val pauseVoiceBroadcastUseCase = PauseVoiceBroadcastUseCase(fakeSession) + + @Test + fun `given a room id with a potential existing voice broadcast state when calling execute then the voice broadcast is paused or not`() = runTest { + val cases = listOf(null).plus(VoiceBroadcastState.values()).map { + when (it) { + VoiceBroadcastState.STARTED, + VoiceBroadcastState.RESUMED -> Case(it, true) + VoiceBroadcastState.STOPPED, + VoiceBroadcastState.PAUSED, + null -> Case(it, false) + } + } + + cases.forEach { case -> + if (case.canPauseVoiceBroadcast) { + testVoiceBroadcastPaused(case.previousState) + } else { + testVoiceBroadcastNotPaused(case.previousState) + } + } + } + + private suspend fun testVoiceBroadcastPaused(previousState: VoiceBroadcastState?) { + // Given + clearAllMocks() + givenAVoiceBroadcastState(previousState) + val voiceBroadcastInfoContentInterceptor = slot() + coEvery { fakeRoom.stateService().sendStateEvent(any(), any(), capture(voiceBroadcastInfoContentInterceptor)) } coAnswers { AN_EVENT_ID } + + // When + pauseVoiceBroadcastUseCase.execute(A_ROOM_ID) + + // Then + coVerify { + fakeRoom.stateService().sendStateEvent( + eventType = STATE_ROOM_VOICE_BROADCAST_INFO, + stateKey = fakeSession.myUserId, + body = any(), + ) + } + val voiceBroadcastInfoContent = voiceBroadcastInfoContentInterceptor.captured.toModel() + voiceBroadcastInfoContent?.voiceBroadcastState shouldBe VoiceBroadcastState.PAUSED + voiceBroadcastInfoContent?.relatesTo?.type shouldBe RelationType.REFERENCE + voiceBroadcastInfoContent?.relatesTo?.eventId shouldBe A_STARTED_VOICE_BROADCAST_EVENT_ID + } + + private suspend fun testVoiceBroadcastNotPaused(previousState: VoiceBroadcastState?) { + // Given + clearAllMocks() + givenAVoiceBroadcastState(previousState) + + // When + pauseVoiceBroadcastUseCase.execute(A_ROOM_ID) + + // Then + coVerify(exactly = 0) { fakeRoom.stateService().sendStateEvent(any(), any(), any()) } + } + + private fun givenAVoiceBroadcastState(state: VoiceBroadcastState?) { + val relatesTo = when (state) { + VoiceBroadcastState.STARTED, + null -> null + VoiceBroadcastState.PAUSED, + VoiceBroadcastState.RESUMED, + VoiceBroadcastState.STOPPED -> RelationDefaultContent(RelationType.REFERENCE, A_STARTED_VOICE_BROADCAST_EVENT_ID) + } + val event = state?.let { + Event( + eventId = if (state == VoiceBroadcastState.STARTED) A_STARTED_VOICE_BROADCAST_EVENT_ID else AN_EVENT_ID, + type = STATE_ROOM_VOICE_BROADCAST_INFO, + stateKey = fakeSession.myUserId, + content = MessageVoiceBroadcastInfoContent( + voiceBroadcastStateStr = state.value, + relatesTo = relatesTo + ).toContent() + ) + } + fakeRoom.stateService().givenGetStateEvent(event) + } + + private data class Case(val previousState: VoiceBroadcastState?, val canPauseVoiceBroadcast: Boolean) +} diff --git a/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/ResumeVoiceBroadcastUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/ResumeVoiceBroadcastUseCaseTest.kt new file mode 100644 index 0000000000..23d506482b --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/ResumeVoiceBroadcastUseCaseTest.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.voicebroadcast.usecase + +import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO +import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.test.fakes.FakeRoom +import im.vector.app.test.fakes.FakeRoomService +import im.vector.app.test.fakes.FakeSession +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.slot +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBe +import org.junit.Test +import org.matrix.android.sdk.api.session.events.model.Content +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.RelationType +import org.matrix.android.sdk.api.session.events.model.toContent +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent + +private const val A_ROOM_ID = "room_id" +private const val AN_EVENT_ID = "event_id" +private const val A_STARTED_VOICE_BROADCAST_EVENT_ID = "a_started_voice_broadcast_event_id" + +class ResumeVoiceBroadcastUseCaseTest { + + private val fakeRoom = FakeRoom() + private val fakeSession = FakeSession(fakeRoomService = FakeRoomService(fakeRoom)) + private val resumeVoiceBroadcastUseCase = ResumeVoiceBroadcastUseCase(fakeSession) + + @Test + fun `given a room id with a potential existing voice broadcast state when calling execute then the voice broadcast is resumed or not`() = runTest { + val cases = listOf(null).plus(VoiceBroadcastState.values()).map { + when (it) { + VoiceBroadcastState.PAUSED -> Case(it, true) + VoiceBroadcastState.STARTED, + VoiceBroadcastState.RESUMED, + VoiceBroadcastState.STOPPED, + null -> Case(it, false) + } + } + + cases.forEach { case -> + if (case.canResumeVoiceBroadcast) { + testVoiceBroadcastResumed(case.previousState) + } else { + testVoiceBroadcastNotResumed(case.previousState) + } + } + } + + private suspend fun testVoiceBroadcastResumed(previousState: VoiceBroadcastState?) { + // Given + clearAllMocks() + givenAVoiceBroadcastState(previousState) + val voiceBroadcastInfoContentInterceptor = slot() + coEvery { fakeRoom.stateService().sendStateEvent(any(), any(), capture(voiceBroadcastInfoContentInterceptor)) } coAnswers { AN_EVENT_ID } + + // When + resumeVoiceBroadcastUseCase.execute(A_ROOM_ID) + + // Then + coVerify { + fakeRoom.stateService().sendStateEvent( + eventType = STATE_ROOM_VOICE_BROADCAST_INFO, + stateKey = fakeSession.myUserId, + body = any(), + ) + } + val voiceBroadcastInfoContent = voiceBroadcastInfoContentInterceptor.captured.toModel() + voiceBroadcastInfoContent?.voiceBroadcastState shouldBe VoiceBroadcastState.RESUMED + voiceBroadcastInfoContent?.relatesTo?.type shouldBe RelationType.REFERENCE + voiceBroadcastInfoContent?.relatesTo?.eventId shouldBe A_STARTED_VOICE_BROADCAST_EVENT_ID + } + + private suspend fun testVoiceBroadcastNotResumed(previousState: VoiceBroadcastState?) { + // Given + clearAllMocks() + givenAVoiceBroadcastState(previousState) + + // When + resumeVoiceBroadcastUseCase.execute(A_ROOM_ID) + + // Then + coVerify(exactly = 0) { fakeRoom.stateService().sendStateEvent(any(), any(), any()) } + } + + private fun givenAVoiceBroadcastState(state: VoiceBroadcastState?) { + val relatesTo = when (state) { + VoiceBroadcastState.STARTED, + null -> null + VoiceBroadcastState.PAUSED, + VoiceBroadcastState.RESUMED, + VoiceBroadcastState.STOPPED -> RelationDefaultContent(RelationType.REFERENCE, A_STARTED_VOICE_BROADCAST_EVENT_ID) + } + val event = state?.let { + Event( + eventId = if (state == VoiceBroadcastState.STARTED) A_STARTED_VOICE_BROADCAST_EVENT_ID else AN_EVENT_ID, + type = STATE_ROOM_VOICE_BROADCAST_INFO, + stateKey = fakeSession.myUserId, + content = MessageVoiceBroadcastInfoContent( + voiceBroadcastStateStr = state.value, + relatesTo = relatesTo + ).toContent() + ) + } + fakeRoom.stateService().givenGetStateEvent(event) + } + + private data class Case(val previousState: VoiceBroadcastState?, val canResumeVoiceBroadcast: Boolean) +} diff --git a/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCaseTest.kt new file mode 100644 index 0000000000..398d6fedf0 --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCaseTest.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.voicebroadcast.usecase + +import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO +import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.test.fakes.FakeRoom +import im.vector.app.test.fakes.FakeRoomService +import im.vector.app.test.fakes.FakeSession +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.slot +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBe +import org.amshove.kluent.shouldBeNull +import org.junit.Test +import org.matrix.android.sdk.api.query.QueryStringValue +import org.matrix.android.sdk.api.session.events.model.Content +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.toContent +import org.matrix.android.sdk.api.session.events.model.toModel + +private const val A_ROOM_ID = "room_id" +private const val AN_EVENT_ID = "event_id" +private const val A_USER_ID = "user_id" + +class StartVoiceBroadcastUseCaseTest { + + private val fakeRoom = FakeRoom() + private val fakeSession = FakeSession(fakeRoomService = FakeRoomService(fakeRoom)) + private val startVoiceBroadcastUseCase = StartVoiceBroadcastUseCase(fakeSession) + + @Test + fun `given a room id with potential several existing voice broadcast states when calling execute then the voice broadcast is started or not`() = runTest { + val cases = VoiceBroadcastState.values() + .flatMap { first -> + VoiceBroadcastState.values().map { second -> + Case( + voiceBroadcasts = listOf(VoiceBroadcast(fakeSession.myUserId, first), VoiceBroadcast(A_USER_ID, second)), + canStartVoiceBroadcast = first == VoiceBroadcastState.STOPPED && second == VoiceBroadcastState.STOPPED + ) + } + } + .plus(Case(emptyList(), true)) + + cases.forEach { case -> + if (case.canStartVoiceBroadcast) { + testVoiceBroadcastStarted(case.voiceBroadcasts) + } else { + testVoiceBroadcastNotStarted(case.voiceBroadcasts) + } + } + } + + private suspend fun testVoiceBroadcastStarted(voiceBroadcasts: List) { + // Given + clearAllMocks() + givenAVoiceBroadcasts(voiceBroadcasts) + val voiceBroadcastInfoContentInterceptor = slot() + coEvery { fakeRoom.stateService().sendStateEvent(any(), any(), capture(voiceBroadcastInfoContentInterceptor)) } coAnswers { AN_EVENT_ID } + + // When + startVoiceBroadcastUseCase.execute(A_ROOM_ID) + + // Then + coVerify { + fakeRoom.stateService().sendStateEvent( + eventType = STATE_ROOM_VOICE_BROADCAST_INFO, + stateKey = fakeSession.myUserId, + body = any(), + ) + } + val voiceBroadcastInfoContent = voiceBroadcastInfoContentInterceptor.captured.toModel() + voiceBroadcastInfoContent?.voiceBroadcastState shouldBe VoiceBroadcastState.STARTED + voiceBroadcastInfoContent?.relatesTo.shouldBeNull() + } + + private suspend fun testVoiceBroadcastNotStarted(voiceBroadcasts: List) { + // Given + clearAllMocks() + givenAVoiceBroadcasts(voiceBroadcasts) + + // When + startVoiceBroadcastUseCase.execute(A_ROOM_ID) + + // Then + coVerify(exactly = 0) { fakeRoom.stateService().sendStateEvent(any(), any(), any()) } + } + + private fun givenAVoiceBroadcasts(voiceBroadcasts: List) { + val events = voiceBroadcasts.map { + Event( + type = STATE_ROOM_VOICE_BROADCAST_INFO, + stateKey = it.userId, + content = MessageVoiceBroadcastInfoContent( + voiceBroadcastStateStr = it.state.value + ).toContent() + ) + } + fakeRoom.stateService().givenGetStateEvents(QueryStringValue.IsNotEmpty, events) + } + + private data class VoiceBroadcast(val userId: String, val state: VoiceBroadcastState) + private data class Case(val voiceBroadcasts: List, val canStartVoiceBroadcast: Boolean) +} diff --git a/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StopVoiceBroadcastUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StopVoiceBroadcastUseCaseTest.kt new file mode 100644 index 0000000000..aa8dcddf30 --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StopVoiceBroadcastUseCaseTest.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.voicebroadcast.usecase + +import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO +import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.test.fakes.FakeRoom +import im.vector.app.test.fakes.FakeRoomService +import im.vector.app.test.fakes.FakeSession +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.slot +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBe +import org.junit.Test +import org.matrix.android.sdk.api.session.events.model.Content +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.RelationType +import org.matrix.android.sdk.api.session.events.model.toContent +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent + +private const val A_ROOM_ID = "room_id" +private const val AN_EVENT_ID = "event_id" +private const val A_STARTED_VOICE_BROADCAST_EVENT_ID = "a_started_voice_broadcast_event_id" + +class StopVoiceBroadcastUseCaseTest { + + private val fakeRoom = FakeRoom() + private val fakeSession = FakeSession(fakeRoomService = FakeRoomService(fakeRoom)) + private val stopVoiceBroadcastUseCase = StopVoiceBroadcastUseCase(fakeSession) + + @Test + fun `given a room id with a potential existing voice broadcast state when calling execute then the voice broadcast is stopped or not`() = runTest { + val cases = listOf(null).plus(VoiceBroadcastState.values()).map { + when (it) { + VoiceBroadcastState.STARTED, + VoiceBroadcastState.RESUMED, + VoiceBroadcastState.PAUSED -> Case(it, true) + VoiceBroadcastState.STOPPED, + null -> Case(it, false) + } + } + + cases.forEach { case -> + if (case.canStopVoiceBroadcast) { + testVoiceBroadcastStopped(case.previousState) + } else { + testVoiceBroadcastNotStopped(case.previousState) + } + } + } + + private suspend fun testVoiceBroadcastStopped(previousState: VoiceBroadcastState?) { + // Given + clearAllMocks() + givenAVoiceBroadcastState(previousState) + val voiceBroadcastInfoContentInterceptor = slot() + coEvery { fakeRoom.stateService().sendStateEvent(any(), any(), capture(voiceBroadcastInfoContentInterceptor)) } coAnswers { AN_EVENT_ID } + + // When + stopVoiceBroadcastUseCase.execute(A_ROOM_ID) + + // Then + coVerify { + fakeRoom.stateService().sendStateEvent( + eventType = STATE_ROOM_VOICE_BROADCAST_INFO, + stateKey = fakeSession.myUserId, + body = any(), + ) + } + val voiceBroadcastInfoContent = voiceBroadcastInfoContentInterceptor.captured.toModel() + voiceBroadcastInfoContent?.voiceBroadcastState shouldBe VoiceBroadcastState.STOPPED + voiceBroadcastInfoContent?.relatesTo?.type shouldBe RelationType.REFERENCE + voiceBroadcastInfoContent?.relatesTo?.eventId shouldBe A_STARTED_VOICE_BROADCAST_EVENT_ID + } + + private suspend fun testVoiceBroadcastNotStopped(previousState: VoiceBroadcastState?) { + // Given + clearAllMocks() + givenAVoiceBroadcastState(previousState) + + // When + stopVoiceBroadcastUseCase.execute(A_ROOM_ID) + + // Then + coVerify(exactly = 0) { fakeRoom.stateService().sendStateEvent(any(), any(), any()) } + } + + private fun givenAVoiceBroadcastState(state: VoiceBroadcastState?) { + val relatesTo = when (state) { + VoiceBroadcastState.STARTED, + null -> null + VoiceBroadcastState.PAUSED, + VoiceBroadcastState.RESUMED, + VoiceBroadcastState.STOPPED -> RelationDefaultContent(RelationType.REFERENCE, A_STARTED_VOICE_BROADCAST_EVENT_ID) + } + val event = state?.let { + Event( + eventId = if (state == VoiceBroadcastState.STARTED) A_STARTED_VOICE_BROADCAST_EVENT_ID else AN_EVENT_ID, + type = STATE_ROOM_VOICE_BROADCAST_INFO, + stateKey = fakeSession.myUserId, + content = MessageVoiceBroadcastInfoContent( + voiceBroadcastStateStr = state.value, + relatesTo = relatesTo + ).toContent() + ) + } + fakeRoom.stateService().givenGetStateEvent(event) + } + + private data class Case(val previousState: VoiceBroadcastState?, val canStopVoiceBroadcast: Boolean) +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeRoom.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeRoom.kt index 865b01551a..7835c314ef 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeRoom.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeRoom.kt @@ -24,6 +24,7 @@ class FakeRoom( private val fakeSendService: FakeSendService = FakeSendService(), private val fakeTimelineService: FakeTimelineService = FakeTimelineService(), private val fakeRelationService: FakeRelationService = FakeRelationService(), + private val fakeStateService: FakeStateService = FakeStateService(), ) : Room by mockk() { override fun locationSharingService() = fakeLocationSharingService @@ -33,4 +34,6 @@ class FakeRoom( override fun timelineService() = fakeTimelineService override fun relationService() = fakeRelationService + + override fun stateService() = fakeStateService } diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeStateService.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeStateService.kt new file mode 100644 index 0000000000..7c393c7a57 --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeStateService.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2021 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.test.fakes + +import io.mockk.every +import io.mockk.mockk +import org.matrix.android.sdk.api.query.QueryStateEventValue +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.room.state.StateService + +class FakeStateService : StateService by mockk(relaxed = true) { + + fun givenGetStateEvents(stateKey: QueryStateEventValue, result: List) { + every { getStateEvents(any(), stateKey) } returns result + } + + fun givenGetStateEvent(event: Event?) { + every { getStateEvent(any(), any()) } returns event + } +}