diff --git a/changelog.d/3833.bugfix b/changelog.d/3833.bugfix new file mode 100644 index 00000000000..7d25fb2aad8 --- /dev/null +++ b/changelog.d/3833.bugfix @@ -0,0 +1 @@ +Fixing queued voice message failing to send or retry \ No newline at end of file diff --git a/changelog.d/3922.feature b/changelog.d/3922.feature new file mode 100644 index 00000000000..bf4e0f7467a --- /dev/null +++ b/changelog.d/3922.feature @@ -0,0 +1 @@ +Voice messages: Persist drafts of voice messages when navigating between rooms \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/content/ContentAttachmentData.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/content/ContentAttachmentData.kt index 7ee26de8db3..4ffb816f77e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/content/ContentAttachmentData.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/content/ContentAttachmentData.kt @@ -22,6 +22,7 @@ import androidx.exifinterface.media.ExifInterface import com.squareup.moshi.JsonClass import kotlinx.parcelize.Parcelize import org.matrix.android.sdk.api.util.MimeTypes.normalizeMimeType +import org.matrix.android.sdk.internal.di.MoshiProvider @Parcelize @JsonClass(generateAdapter = true) @@ -48,4 +49,14 @@ data class ContentAttachmentData( } fun getSafeMimeType() = mimeType?.normalizeMimeType() + + fun toJsonString(): String { + return MoshiProvider.providesMoshi().adapter(ContentAttachmentData::class.java).toJson(this) + } + + companion object { + fun fromJsonString(json: String): ContentAttachmentData? { + return MoshiProvider.providesMoshi().adapter(ContentAttachmentData::class.java).fromJson(json) + } + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/UserDraft.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/UserDraft.kt index 9471b3dbcbb..a8c0de2fa50 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/UserDraft.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/UserDraft.kt @@ -23,15 +23,16 @@ package org.matrix.android.sdk.api.session.room.send * EDIT: draft of an edition of a message * REPLY: draft of a reply of another message */ -sealed class UserDraft(open val text: String) { - data class REGULAR(override val text: String) : UserDraft(text) - data class QUOTE(val linkedEventId: String, override val text: String) : UserDraft(text) - data class EDIT(val linkedEventId: String, override val text: String) : UserDraft(text) - data class REPLY(val linkedEventId: String, override val text: String) : UserDraft(text) +sealed interface UserDraft { + data class Regular(val content: String) : UserDraft + data class Quote(val linkedEventId: String, val content: String) : UserDraft + data class Edit(val linkedEventId: String, val content: String) : UserDraft + data class Reply(val linkedEventId: String, val content: String) : UserDraft + data class Voice(val content: String) : UserDraft fun isValid(): Boolean { return when (this) { - is REGULAR -> text.isNotBlank() + is Regular -> content.isNotBlank() else -> true } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/DraftMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/DraftMapper.kt index 148f727ba71..737c4b4608d 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/DraftMapper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/DraftMapper.kt @@ -26,20 +26,22 @@ internal object DraftMapper { fun map(entity: DraftEntity): UserDraft { return when (entity.draftMode) { - DraftEntity.MODE_REGULAR -> UserDraft.REGULAR(entity.content) - DraftEntity.MODE_EDIT -> UserDraft.EDIT(entity.linkedEventId, entity.content) - DraftEntity.MODE_QUOTE -> UserDraft.QUOTE(entity.linkedEventId, entity.content) - DraftEntity.MODE_REPLY -> UserDraft.REPLY(entity.linkedEventId, entity.content) + DraftEntity.MODE_REGULAR -> UserDraft.Regular(entity.content) + DraftEntity.MODE_EDIT -> UserDraft.Edit(entity.linkedEventId, entity.content) + DraftEntity.MODE_QUOTE -> UserDraft.Quote(entity.linkedEventId, entity.content) + DraftEntity.MODE_REPLY -> UserDraft.Reply(entity.linkedEventId, entity.content) + DraftEntity.MODE_VOICE -> UserDraft.Voice(entity.content) else -> null - } ?: UserDraft.REGULAR("") + } ?: UserDraft.Regular("") } fun map(domain: UserDraft): DraftEntity { return when (domain) { - is UserDraft.REGULAR -> DraftEntity(content = domain.text, draftMode = DraftEntity.MODE_REGULAR, linkedEventId = "") - is UserDraft.EDIT -> DraftEntity(content = domain.text, draftMode = DraftEntity.MODE_EDIT, linkedEventId = domain.linkedEventId) - is UserDraft.QUOTE -> DraftEntity(content = domain.text, draftMode = DraftEntity.MODE_QUOTE, linkedEventId = domain.linkedEventId) - is UserDraft.REPLY -> DraftEntity(content = domain.text, draftMode = DraftEntity.MODE_REPLY, linkedEventId = domain.linkedEventId) + is UserDraft.Regular -> DraftEntity(content = domain.content, draftMode = DraftEntity.MODE_REGULAR, linkedEventId = "") + is UserDraft.Edit -> DraftEntity(content = domain.content, draftMode = DraftEntity.MODE_EDIT, linkedEventId = domain.linkedEventId) + is UserDraft.Quote -> DraftEntity(content = domain.content, draftMode = DraftEntity.MODE_QUOTE, linkedEventId = domain.linkedEventId) + is UserDraft.Reply -> DraftEntity(content = domain.content, draftMode = DraftEntity.MODE_REPLY, linkedEventId = domain.linkedEventId) + is UserDraft.Voice -> DraftEntity(content = domain.content, draftMode = DraftEntity.MODE_VOICE, linkedEventId = "") } } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/DraftEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/DraftEntity.kt index 15a5d379631..fd09da4448f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/DraftEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/DraftEntity.kt @@ -21,7 +21,6 @@ import io.realm.RealmObject internal open class DraftEntity(var content: String = "", var draftMode: String = MODE_REGULAR, var linkedEventId: String = "" - ) : RealmObject() { companion object { @@ -29,5 +28,6 @@ internal open class DraftEntity(var content: String = "", const val MODE_EDIT = "EDIT" const val MODE_REPLY = "REPLY" const val MODE_QUOTE = "QUOTE" + const val MODE_VOICE = "VOICE" } } diff --git a/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt b/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt index 69257f1f051..cac694e84ea 100644 --- a/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt @@ -42,6 +42,7 @@ import im.vector.app.features.home.UnknownDeviceDetectorSharedViewModel import im.vector.app.features.home.UnreadMessagesSharedViewModel import im.vector.app.features.home.room.breadcrumbs.BreadcrumbsViewModel import im.vector.app.features.home.room.detail.RoomDetailViewModel +import im.vector.app.features.home.room.detail.composer.MessageComposerViewModel import im.vector.app.features.home.room.detail.search.SearchViewModel import im.vector.app.features.home.room.detail.timeline.action.MessageActionsViewModel import im.vector.app.features.home.room.detail.timeline.edithistory.ViewEditHistoryViewModel @@ -508,6 +509,11 @@ interface MavericksViewModelModule { @MavericksViewModelKey(RoomDetailViewModel::class) fun roomDetailViewModelFactory(factory: RoomDetailViewModel.Factory): MavericksAssistedViewModelFactory<*, *> + @Binds + @IntoMap + @MavericksViewModelKey(MessageComposerViewModel::class) + fun messageComposerViewModelFactory(factory: MessageComposerViewModel.Factory): MavericksAssistedViewModelFactory<*, *> + @Binds @IntoMap @MavericksViewModelKey(SetIdentityServerViewModel::class) 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 25c2c0b7954..e1122761168 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 @@ -241,7 +241,6 @@ class RoomDetailFragment @Inject constructor( autoCompleterFactory: AutoCompleter.Factory, private val permalinkHandler: PermalinkHandler, private val notificationDrawerManager: NotificationDrawerManager, - val messageComposerViewModelFactory: MessageComposerViewModel.Factory, private val eventHtmlRenderer: EventHtmlRenderer, private val vectorPreferences: VectorPreferences, private val colorProvider: ColorProvider, @@ -392,10 +391,11 @@ class RoomDetailFragment @Inject constructor( return@onEach } when (mode) { - is SendMode.REGULAR -> renderRegularMode(mode.text) - is SendMode.EDIT -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_edit, R.string.edit, mode.text) - is SendMode.QUOTE -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_quote, R.string.quote, mode.text) - is SendMode.REPLY -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_reply, R.string.reply, mode.text) + is SendMode.Regular -> renderRegularMode(mode.text) + is SendMode.Edit -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_edit, R.string.edit, mode.text) + is SendMode.Quote -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_quote, R.string.quote, mode.text) + is SendMode.Reply -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_reply, R.string.reply, mode.text) + is SendMode.Voice -> renderVoiceMessageMode(mode.text) } } @@ -471,6 +471,13 @@ class RoomDetailFragment @Inject constructor( } } + private fun renderVoiceMessageMode(content: String) { + ContentAttachmentData.fromJsonString(content)?.let { audioAttachmentData -> + views.voiceMessageRecorderView.isVisible = true + messageComposerViewModel.handle(MessageComposerAction.InitializeVoiceRecorder(audioAttachmentData)) + } + } + private fun handleSendButtonVisibilityChanged(event: MessageComposerViewEvents.AnimateSendButtonVisibility) { if (event.isVisible) { views.voiceMessageRecorderView.isVisible = false @@ -507,7 +514,7 @@ class RoomDetailFragment @Inject constructor( private fun onCannotRecord() { // Update the UI, cancel the animation - messageComposerViewModel.handle(MessageComposerAction.OnVoiceRecordingUiStateChanged(RecordingUiState.None)) + messageComposerViewModel.handle(MessageComposerAction.OnVoiceRecordingUiStateChanged(RecordingUiState.Idle)) } private fun acceptIncomingCall(event: RoomDetailViewEvents.DisplayAndAcceptCall) { @@ -701,7 +708,7 @@ class RoomDetailFragment @Inject constructor( if (checkPermissions(PERMISSIONS_FOR_VOICE_MESSAGE, requireActivity(), permissionVoiceMessageLauncher)) { messageComposerViewModel.handle(MessageComposerAction.StartRecordingVoiceMessage) vibrate(requireContext()) - updateRecordingUiState(RecordingUiState.Started) + updateRecordingUiState(RecordingUiState.Recording) } } @@ -711,7 +718,8 @@ class RoomDetailFragment @Inject constructor( override fun onVoiceRecordingCancelled() { messageComposerViewModel.handle(MessageComposerAction.EndRecordingVoiceMessage(isCancelled = true)) - updateRecordingUiState(RecordingUiState.Cancelled) + vibrate(requireContext()) + updateRecordingUiState(RecordingUiState.Idle) } override fun onVoiceRecordingLocked() { @@ -724,22 +732,22 @@ class RoomDetailFragment @Inject constructor( override fun onSendVoiceMessage() { messageComposerViewModel.handle(MessageComposerAction.EndRecordingVoiceMessage(isCancelled = false)) - updateRecordingUiState(RecordingUiState.None) + updateRecordingUiState(RecordingUiState.Idle) } override fun onDeleteVoiceMessage() { messageComposerViewModel.handle(MessageComposerAction.EndRecordingVoiceMessage(isCancelled = true)) - updateRecordingUiState(RecordingUiState.None) + updateRecordingUiState(RecordingUiState.Idle) } override fun onRecordingLimitReached() { messageComposerViewModel.handle(MessageComposerAction.PauseRecordingVoiceMessage) - updateRecordingUiState(RecordingUiState.Playback) + updateRecordingUiState(RecordingUiState.Draft) } override fun onRecordingWaveformClicked() { messageComposerViewModel.handle(MessageComposerAction.PauseRecordingVoiceMessage) - updateRecordingUiState(RecordingUiState.Playback) + updateRecordingUiState(RecordingUiState.Draft) } private fun updateRecordingUiState(state: RecordingUiState) { @@ -1044,10 +1052,10 @@ class RoomDetailFragment @Inject constructor( .show() } - private fun renderRegularMode(text: String) { + private fun renderRegularMode(content: String) { autoCompleter.exitSpecialMode() views.composerLayout.collapse() - views.composerLayout.setTextIfDifferent(text) + views.composerLayout.setTextIfDifferent(content) views.composerLayout.views.sendButton.contentDescription = getString(R.string.send) } @@ -1131,14 +1139,9 @@ class RoomDetailFragment @Inject constructor( override fun onPause() { super.onPause() - notificationDrawerManager.setCurrentRoom(null) - - messageComposerViewModel.handle(MessageComposerAction.SaveDraft(views.composerLayout.text.toString())) - - // We should improve the UX to support going into playback mode when paused and delete the media when the view is destroyed. - messageComposerViewModel.handle(MessageComposerAction.EndAllVoiceActions(deleteRecord = false)) - views.voiceMessageRecorderView.render(RecordingUiState.None) + voiceMessagePlaybackTracker.unTrack(VoiceMessagePlaybackTracker.RECORDING_ID) + messageComposerViewModel.handle(MessageComposerAction.OnEntersBackground(views.composerLayout.text.toString())) } private val attachmentFileActivityResultLauncher = registerStartForActivityResult { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt index 392a52e7fad..690f127cbd6 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt @@ -18,10 +18,10 @@ package im.vector.app.features.home.room.detail.composer import im.vector.app.core.platform.VectorViewModelAction import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView +import org.matrix.android.sdk.api.session.content.ContentAttachmentData import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent sealed class MessageComposerAction : VectorViewModelAction { - data class SaveDraft(val draft: String) : MessageComposerAction() data class SendMessage(val text: CharSequence, val autoMarkdown: Boolean) : MessageComposerAction() data class EnterEditMode(val eventId: String, val text: String) : MessageComposerAction() data class EnterQuoteMode(val eventId: String, val text: String) : MessageComposerAction() @@ -29,8 +29,10 @@ sealed class MessageComposerAction : VectorViewModelAction { data class EnterRegularMode(val text: String, val fromSharing: Boolean) : MessageComposerAction() data class UserIsTyping(val isTyping: Boolean) : MessageComposerAction() data class OnTextChanged(val text: CharSequence) : MessageComposerAction() + data class OnEntersBackground(val composerText: String) : MessageComposerAction() // Voice Message + data class InitializeVoiceRecorder(val attachmentData: ContentAttachmentData) : MessageComposerAction() data class OnVoiceRecordingUiStateChanged(val uiState: VoiceMessageRecorderView.RecordingUiState) : MessageComposerAction() object StartRecordingVoiceMessage : MessageComposerAction() data class EndRecordingVoiceMessage(val isCancelled: Boolean) : MessageComposerAction() 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 b9932e32ee6..6ac33ef4e09 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 @@ -16,13 +16,13 @@ package im.vector.app.features.home.room.detail.composer -import com.airbnb.mvrx.FragmentViewModelContext import com.airbnb.mvrx.MavericksViewModelFactory -import com.airbnb.mvrx.ViewModelContext import dagger.assisted.Assisted import dagger.assisted.AssistedFactory 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.exhaustive import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.resources.StringProvider @@ -30,8 +30,8 @@ import im.vector.app.features.attachments.toContentAttachmentData import im.vector.app.features.command.CommandParser import im.vector.app.features.command.ParsedCommand import im.vector.app.features.home.room.detail.ChatEffect -import im.vector.app.features.home.room.detail.RoomDetailFragment import im.vector.app.features.home.room.detail.composer.rainbow.RainbowGenerator +import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView import im.vector.app.features.home.room.detail.toMessageType import im.vector.app.features.powerlevel.PowerLevelsFlowFactory import im.vector.app.features.session.coroutineScope @@ -43,6 +43,7 @@ import org.commonmark.parser.Parser import org.commonmark.renderer.html.HtmlRenderer import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.content.ContentAttachmentData import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.toContent import org.matrix.android.sdk.api.session.events.model.toModel @@ -86,17 +87,18 @@ class MessageComposerViewModel @AssistedInject constructor( is MessageComposerAction.EnterQuoteMode -> handleEnterQuoteMode(action) is MessageComposerAction.EnterRegularMode -> handleEnterRegularMode(action) is MessageComposerAction.EnterReplyMode -> handleEnterReplyMode(action) - is MessageComposerAction.SaveDraft -> handleSaveDraft(action) is MessageComposerAction.SendMessage -> handleSendMessage(action) is MessageComposerAction.UserIsTyping -> handleUserIsTyping(action) is MessageComposerAction.OnTextChanged -> handleOnTextChanged(action) is MessageComposerAction.OnVoiceRecordingUiStateChanged -> handleOnVoiceRecordingUiStateChanged(action) - MessageComposerAction.StartRecordingVoiceMessage -> handleStartRecordingVoiceMessage() + is MessageComposerAction.StartRecordingVoiceMessage -> handleStartRecordingVoiceMessage() is MessageComposerAction.EndRecordingVoiceMessage -> handleEndRecordingVoiceMessage(action.isCancelled) is MessageComposerAction.PlayOrPauseVoicePlayback -> handlePlayOrPauseVoicePlayback(action) MessageComposerAction.PauseRecordingVoiceMessage -> handlePauseRecordingVoiceMessage() MessageComposerAction.PlayOrPauseRecordingPlayback -> handlePlayOrPauseRecordingPlayback() is MessageComposerAction.EndAllVoiceActions -> handleEndAllVoiceActions(action.deleteRecord) + is MessageComposerAction.InitializeVoiceRecorder -> handleInitializeVoiceRecorder(action.attachmentData) + is MessageComposerAction.OnEntersBackground -> handleEntersBackground(action.composerText) } } @@ -120,7 +122,7 @@ class MessageComposerViewModel @AssistedInject constructor( } private fun updateIsSendButtonVisibility(triggerAnimation: Boolean) = setState { - val isSendButtonVisible = isComposerVisible && (sendMode !is SendMode.REGULAR || currentComposerText.isNotBlank()) + val isSendButtonVisible = isComposerVisible && (sendMode !is SendMode.Regular || currentComposerText.isNotBlank()) if (this.isSendButtonVisible != isSendButtonVisible && triggerAnimation) { _viewEvents.post(MessageComposerViewEvents.AnimateSendButtonVisibility(isSendButtonVisible)) } @@ -128,12 +130,12 @@ class MessageComposerViewModel @AssistedInject constructor( } private fun handleEnterRegularMode(action: MessageComposerAction.EnterRegularMode) = setState { - copy(sendMode = SendMode.REGULAR(action.text, action.fromSharing)) + copy(sendMode = SendMode.Regular(action.text, action.fromSharing)) } private fun handleEnterEditMode(action: MessageComposerAction.EnterEditMode) { room.getTimeLineEvent(action.eventId)?.let { timelineEvent -> - setState { copy(sendMode = SendMode.EDIT(timelineEvent, timelineEvent.getTextEditableContent())) } + setState { copy(sendMode = SendMode.Edit(timelineEvent, timelineEvent.getTextEditableContent())) } } } @@ -147,20 +149,20 @@ class MessageComposerViewModel @AssistedInject constructor( private fun handleEnterQuoteMode(action: MessageComposerAction.EnterQuoteMode) { room.getTimeLineEvent(action.eventId)?.let { timelineEvent -> - setState { copy(sendMode = SendMode.QUOTE(timelineEvent, action.text)) } + setState { copy(sendMode = SendMode.Quote(timelineEvent, action.text)) } } } private fun handleEnterReplyMode(action: MessageComposerAction.EnterReplyMode) { room.getTimeLineEvent(action.eventId)?.let { timelineEvent -> - setState { copy(sendMode = SendMode.REPLY(timelineEvent, action.text)) } + setState { copy(sendMode = SendMode.Reply(timelineEvent, action.text)) } } } private fun handleSendMessage(action: MessageComposerAction.SendMessage) { withState { state -> when (state.sendMode) { - is SendMode.REGULAR -> { + is SendMode.Regular -> { when (val slashCommandResult = CommandParser.parseSplashCommand(action.text)) { is ParsedCommand.ErrorNotACommand -> { // Send the text message to the room @@ -382,7 +384,7 @@ class MessageComposerViewModel @AssistedInject constructor( } }.exhaustive } - is SendMode.EDIT -> { + is SendMode.Edit -> { // is original event a reply? val inReplyTo = state.sendMode.timelineEvent.getRelationContent()?.inReplyTo?.eventId if (inReplyTo != null) { @@ -405,7 +407,7 @@ class MessageComposerViewModel @AssistedInject constructor( _viewEvents.post(MessageComposerViewEvents.MessageSent) popDraft() } - is SendMode.QUOTE -> { + is SendMode.Quote -> { val messageContent = state.sendMode.timelineEvent.getLastMessageContent() val textMsg = messageContent?.body @@ -426,24 +428,27 @@ class MessageComposerViewModel @AssistedInject constructor( _viewEvents.post(MessageComposerViewEvents.MessageSent) popDraft() } - is SendMode.REPLY -> { + is SendMode.Reply -> { state.sendMode.timelineEvent.let { room.replyToMessage(it, action.text.toString(), action.autoMarkdown) _viewEvents.post(MessageComposerViewEvents.MessageSent) popDraft() } } + is SendMode.Voice -> { + // do nothing + } }.exhaustive } } private fun popDraft() = withState { - if (it.sendMode is SendMode.REGULAR && it.sendMode.fromSharing) { + if (it.sendMode is SendMode.Regular && it.sendMode.fromSharing) { // If we were sharing, we want to get back our last value from draft loadDraftIfAny() } else { // Otherwise we clear the composer and remove the draft from db - setState { copy(sendMode = SendMode.REGULAR("", false)) } + setState { copy(sendMode = SendMode.Regular("", false)) } viewModelScope.launch { room.deleteDraft() } @@ -456,24 +461,25 @@ class MessageComposerViewModel @AssistedInject constructor( copy( // Create a sendMode from a draft and retrieve the TimelineEvent sendMode = when (currentDraft) { - is UserDraft.REGULAR -> SendMode.REGULAR(currentDraft.text, false) - is UserDraft.QUOTE -> { + is UserDraft.Regular -> SendMode.Regular(currentDraft.content, false) + is UserDraft.Quote -> { room.getTimeLineEvent(currentDraft.linkedEventId)?.let { timelineEvent -> - SendMode.QUOTE(timelineEvent, currentDraft.text) + SendMode.Quote(timelineEvent, currentDraft.content) } } - is UserDraft.REPLY -> { + is UserDraft.Reply -> { room.getTimeLineEvent(currentDraft.linkedEventId)?.let { timelineEvent -> - SendMode.REPLY(timelineEvent, currentDraft.text) + SendMode.Reply(timelineEvent, currentDraft.content) } } - is UserDraft.EDIT -> { + is UserDraft.Edit -> { room.getTimeLineEvent(currentDraft.linkedEventId)?.let { timelineEvent -> - SendMode.EDIT(timelineEvent, currentDraft.text) + SendMode.Edit(timelineEvent, currentDraft.content) } } + is UserDraft.Voice -> SendMode.Voice(currentDraft.content) else -> null - } ?: SendMode.REGULAR("", fromSharing = false) + } ?: SendMode.Regular("", fromSharing = false) ) } } @@ -676,24 +682,24 @@ class MessageComposerViewModel @AssistedInject constructor( /** * Convert a send mode to a draft and save the draft */ - private fun handleSaveDraft(action: MessageComposerAction.SaveDraft) = withState { + private fun handleSaveTextDraft(draft: String) = withState { session.coroutineScope.launch { when { - it.sendMode is SendMode.REGULAR && !it.sendMode.fromSharing -> { - setState { copy(sendMode = it.sendMode.copy(action.draft)) } - room.saveDraft(UserDraft.REGULAR(action.draft)) + it.sendMode is SendMode.Regular && !it.sendMode.fromSharing -> { + setState { copy(sendMode = it.sendMode.copy(text = draft)) } + room.saveDraft(UserDraft.Regular(draft)) } - it.sendMode is SendMode.REPLY -> { - setState { copy(sendMode = it.sendMode.copy(text = action.draft)) } - room.saveDraft(UserDraft.REPLY(it.sendMode.timelineEvent.root.eventId!!, action.draft)) + it.sendMode is SendMode.Reply -> { + setState { copy(sendMode = it.sendMode.copy(text = draft)) } + room.saveDraft(UserDraft.Reply(it.sendMode.timelineEvent.root.eventId!!, draft)) } - it.sendMode is SendMode.QUOTE -> { - setState { copy(sendMode = it.sendMode.copy(text = action.draft)) } - room.saveDraft(UserDraft.QUOTE(it.sendMode.timelineEvent.root.eventId!!, action.draft)) + it.sendMode is SendMode.Quote -> { + setState { copy(sendMode = it.sendMode.copy(text = draft)) } + room.saveDraft(UserDraft.Quote(it.sendMode.timelineEvent.root.eventId!!, draft)) } - it.sendMode is SendMode.EDIT -> { - setState { copy(sendMode = it.sendMode.copy(text = action.draft)) } - room.saveDraft(UserDraft.EDIT(it.sendMode.timelineEvent.root.eventId!!, action.draft)) + it.sendMode is SendMode.Edit -> { + setState { copy(sendMode = it.sendMode.copy(text = draft)) } + room.saveDraft(UserDraft.Edit(it.sendMode.timelineEvent.root.eventId!!, draft)) } } } @@ -701,7 +707,7 @@ class MessageComposerViewModel @AssistedInject constructor( private fun handleStartRecordingVoiceMessage() { try { - voiceMessageHelper.startRecording() + voiceMessageHelper.startRecording(room.roomId) } catch (failure: Throwable) { _viewEvents.post(MessageComposerViewEvents.VoicePlaybackOrRecordingFailure(failure)) } @@ -720,6 +726,7 @@ class MessageComposerViewModel @AssistedInject constructor( } } } + handleEnterRegularMode(MessageComposerAction.EnterRegularMode(text = "", fromSharing = false)) } private fun handlePlayOrPauseVoicePlayback(action: MessageComposerAction.PlayOrPauseVoicePlayback) { @@ -742,13 +749,35 @@ class MessageComposerViewModel @AssistedInject constructor( } private fun handleEndAllVoiceActions(deleteRecord: Boolean) { + voiceMessageHelper.clearTracker() voiceMessageHelper.stopAllVoiceActions(deleteRecord) } + private fun handleInitializeVoiceRecorder(attachmentData: ContentAttachmentData) { + voiceMessageHelper.initializeRecorder(attachmentData) + setState { copy(voiceRecordingUiState = VoiceMessageRecorderView.RecordingUiState.Draft) } + } + private fun handlePauseRecordingVoiceMessage() { voiceMessageHelper.pauseRecording() } + private fun handleEntersBackground(composerText: String) { + val isVoiceRecording = com.airbnb.mvrx.withState(this) { it.isVoiceRecording } + if (isVoiceRecording) { + voiceMessageHelper.clearTracker() + viewModelScope.launch { + voiceMessageHelper.stopAllVoiceActions(deleteRecord = false)?.toContentAttachmentData()?.let { voiceDraft -> + val content = voiceDraft.toJsonString() + room.saveDraft(UserDraft.Voice(content)) + setState { copy(sendMode = SendMode.Voice(content)) } + } + } + } else { + handleSaveTextDraft(draft = composerText) + } + } + private fun launchSlashCommandFlowSuspendable(block: suspend () -> Unit) { _viewEvents.post(MessageComposerViewEvents.SlashCommandLoading) viewModelScope.launch { @@ -764,23 +793,9 @@ class MessageComposerViewModel @AssistedInject constructor( } @AssistedFactory - interface Factory { - fun create(initialState: MessageComposerViewState): MessageComposerViewModel + interface Factory : MavericksAssistedViewModelFactory { + override fun create(initialState: MessageComposerViewState): MessageComposerViewModel } - /** - * We're unable to create this ViewModel with `by hiltMavericksViewModelFactory()` due to the - * VoiceMessagePlaybackTracker being ActivityScoped - * - * This factory allows us to provide the ViewModel instance from the Fragment directly - * bypassing the Singleton scope requirement - */ - companion object : MavericksViewModelFactory { - - @JvmStatic - override fun create(viewModelContext: ViewModelContext, state: MessageComposerViewState): MessageComposerViewModel { - val fragment: RoomDetailFragment = (viewModelContext as FragmentViewModelContext).fragment() - return fragment.messageComposerViewModelFactory.create(state) - } - } + companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt index def1c8d2fb1..3aa1548312a 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt @@ -29,39 +29,36 @@ import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent * * Depending on the state the bottom toolbar will change (icons/preview/actions...) */ -sealed class SendMode(open val text: String) { - data class REGULAR( - override val text: String, +sealed interface SendMode { + data class Regular( + val text: String, val fromSharing: Boolean, // This is necessary for forcing refresh on selectSubscribe private val ts: Long = System.currentTimeMillis() - ) : SendMode(text) + ) : SendMode - data class QUOTE(val timelineEvent: TimelineEvent, override val text: String) : SendMode(text) - data class EDIT(val timelineEvent: TimelineEvent, override val text: String) : SendMode(text) - data class REPLY(val timelineEvent: TimelineEvent, override val text: String) : SendMode(text) + data class Quote(val timelineEvent: TimelineEvent, val text: String) : SendMode + data class Edit(val timelineEvent: TimelineEvent, val text: String) : SendMode + data class Reply(val timelineEvent: TimelineEvent, val text: String) : SendMode + data class Voice(val text: String) : SendMode } data class MessageComposerViewState( val roomId: String, val canSendMessage: Boolean = true, val isSendButtonVisible: Boolean = false, - val sendMode: SendMode = SendMode.REGULAR("", false), - val voiceRecordingUiState: VoiceMessageRecorderView.RecordingUiState = VoiceMessageRecorderView.RecordingUiState.None + val sendMode: SendMode = SendMode.Regular("", false), + val voiceRecordingUiState: VoiceMessageRecorderView.RecordingUiState = VoiceMessageRecorderView.RecordingUiState.Idle ) : MavericksState { val isVoiceRecording = when (voiceRecordingUiState) { - VoiceMessageRecorderView.RecordingUiState.None, - VoiceMessageRecorderView.RecordingUiState.Cancelled, - VoiceMessageRecorderView.RecordingUiState.Playback -> false + VoiceMessageRecorderView.RecordingUiState.Idle -> false VoiceMessageRecorderView.RecordingUiState.Locked, - VoiceMessageRecorderView.RecordingUiState.Started -> true + VoiceMessageRecorderView.RecordingUiState.Draft, + VoiceMessageRecorderView.RecordingUiState.Recording -> true } - val isVoiceMessageIdle = when (voiceRecordingUiState) { - VoiceMessageRecorderView.RecordingUiState.None, VoiceMessageRecorderView.RecordingUiState.Cancelled -> false - else -> true - } + val isVoiceMessageIdle = !isVoiceRecording val isComposerVisible = canSendMessage && !isVoiceRecording val isVoiceMessageRecorderVisible = canSendMessage && !isSendButtonVisible diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/VoiceMessageHelper.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/VoiceMessageHelper.kt index adcd6a3008e..f13f8ec8f60 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/VoiceMessageHelper.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/VoiceMessageHelper.kt @@ -30,6 +30,7 @@ import im.vector.lib.multipicker.entity.MultiPickerAudioType import im.vector.lib.multipicker.utils.toMultiPickerAudioType import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.extensions.tryOrNull +import org.matrix.android.sdk.api.session.content.ContentAttachmentData import timber.log.Timber import java.io.File import java.io.FileInputStream @@ -52,13 +53,22 @@ class VoiceMessageHelper @Inject constructor( private var amplitudeTicker: CountUpTimer? = null private var playbackTicker: CountUpTimer? = null - fun startRecording() { + fun initializeRecorder(attachmentData: ContentAttachmentData) { + voiceRecorder.initializeRecord(attachmentData) + amplitudeList.clear() + attachmentData.waveform?.let { + amplitudeList.addAll(it) + playbackTracker.updateCurrentRecording(VoiceMessagePlaybackTracker.RECORDING_ID, amplitudeList) + } + } + + fun startRecording(roomId: String) { stopPlayback() playbackTracker.makeAllPlaybacksIdle() amplitudeList.clear() try { - voiceRecorder.startRecord() + voiceRecorder.startRecord(roomId) } catch (failure: Throwable) { Timber.e(failure, "Unable to start recording") throw VoiceFailure.UnableToRecord(failure) @@ -76,9 +86,9 @@ class VoiceMessageHelper @Inject constructor( } try { voiceMessageFile?.let { - val outputFileUri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileProvider", it) + val outputFileUri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileProvider", it, "Voice message.${it.extension}") return outputFileUri - ?.toMultiPickerAudioType(context) + .toMultiPickerAudioType(context) ?.apply { waveform = if (amplitudeList.size < 50) { amplitudeList @@ -217,12 +227,16 @@ class VoiceMessageHelper @Inject constructor( playbackTicker = null } - fun stopAllVoiceActions(deleteRecord: Boolean = true) { - stopRecording() + fun clearTracker() { + playbackTracker.clear() + } + + fun stopAllVoiceActions(deleteRecord: Boolean = true): MultiPickerAudioType? { + val audioType = stopRecording() stopPlayback() if (deleteRecord) { deleteRecording() } - playbackTracker.clear() + return audioType } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt index 14d5a582790..891a8d1d7ae 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt @@ -87,7 +87,14 @@ class VoiceMessageRecorderView @JvmOverloads constructor( override fun onSendVoiceMessage() = callback.onSendVoiceMessage() override fun onDeleteVoiceMessage() = callback.onDeleteVoiceMessage() - override fun onWaveformClicked() = callback.onRecordingWaveformClicked() + override fun onWaveformClicked() { + when (lastKnownState) { + RecordingUiState.Draft -> callback.onVoicePlaybackButtonClicked() + RecordingUiState.Recording, + RecordingUiState.Locked -> callback.onRecordingWaveformClicked() + } + } + override fun onVoicePlaybackButtonClicked() = callback.onVoicePlaybackButtonClicked() override fun onMicButtonDrag(nextDragStateCreator: (DraggingState) -> DraggingState) { onDrag(dragState, newDragState = nextDragStateCreator(dragState)) @@ -107,28 +114,24 @@ class VoiceMessageRecorderView @JvmOverloads constructor( if (lastKnownState == recordingState) return lastKnownState = recordingState when (recordingState) { - RecordingUiState.None -> { + RecordingUiState.Idle -> { reset() } - RecordingUiState.Started -> { + RecordingUiState.Recording -> { startRecordingTicker() voiceMessageViews.renderToast(context.getString(R.string.voice_message_release_to_send_toast)) voiceMessageViews.showRecordingViews() dragState = DraggingState.Ready } - RecordingUiState.Cancelled -> { - reset() - vibrate(context) - } RecordingUiState.Locked -> { voiceMessageViews.renderLocked() postDelayed({ voiceMessageViews.showRecordingLockedViews(recordingState) }, 500) } - RecordingUiState.Playback -> { + RecordingUiState.Draft -> { stopRecordingTicker() - voiceMessageViews.showPlaybackViews() + voiceMessageViews.showDraftViews() } } } @@ -209,11 +212,10 @@ class VoiceMessageRecorderView @JvmOverloads constructor( } sealed interface RecordingUiState { - object None : RecordingUiState - object Started : RecordingUiState - object Cancelled : RecordingUiState + object Idle : RecordingUiState + object Recording : RecordingUiState object Locked : RecordingUiState - object Playback : RecordingUiState + object Draft : RecordingUiState } sealed interface DraggingState { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt index 32f21a31770..a69f45a0304 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt @@ -23,9 +23,11 @@ import android.view.MotionEvent import android.view.View import android.view.ViewGroup import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.doOnLayout import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams +import com.visualizer.amplitude.AudioRecordView import im.vector.app.R import im.vector.app.core.extensions.setAttributeBackground import im.vector.app.core.extensions.setAttributeTintedBackground @@ -195,7 +197,7 @@ class VoiceMessageViews( } // Hide toasts if user cancelled recording before the timeout of the toast. - if (recordingState == RecordingUiState.Cancelled || recordingState == RecordingUiState.None) { + if (recordingState == RecordingUiState.Idle) { hideToast() } } @@ -258,6 +260,16 @@ class VoiceMessageViews( views.voiceMessageToast.isVisible = false } + fun showDraftViews() { + hideRecordingViews(RecordingUiState.Idle) + views.voiceMessageMicButton.isVisible = false + views.voiceMessageSendButton.isVisible = true + views.voiceMessagePlaybackLayout.isVisible = true + views.voiceMessagePlaybackTimerIndicator.isVisible = false + views.voicePlaybackControlButton.isVisible = true + views.voicePlaybackWaveform.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + } + fun showRecordingLockedViews(recordingState: RecordingUiState) { hideRecordingViews(recordingState) views.voiceMessagePlaybackLayout.isVisible = true @@ -268,14 +280,8 @@ class VoiceMessageViews( renderToast(resources.getString(R.string.voice_message_tap_to_stop_toast)) } - fun showPlaybackViews() { - views.voiceMessagePlaybackTimerIndicator.isVisible = false - views.voicePlaybackControlButton.isVisible = true - views.voicePlaybackWaveform.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - } - fun initViews() { - hideRecordingViews(RecordingUiState.None) + hideRecordingViews(RecordingUiState.Idle) views.voiceMessageMicButton.isVisible = true views.voiceMessageSendButton.isVisible = false views.voicePlaybackWaveform.post { views.voicePlaybackWaveform.recreate() } @@ -320,11 +326,9 @@ class VoiceMessageViews( } fun renderRecordingWaveform(amplitudeList: Array) { - views.voicePlaybackWaveform.post { - views.voicePlaybackWaveform.apply { - amplitudeList.iterator().forEach { - update(it) - } + views.voicePlaybackWaveform.doOnLayout { waveFormView -> + amplitudeList.iterator().forEach { + (waveFormView as AudioRecordView).update(it) } } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/VoiceMessagePlaybackTracker.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/VoiceMessagePlaybackTracker.kt index 2e8f6d93364..86cc792e7b3 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/VoiceMessagePlaybackTracker.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/VoiceMessagePlaybackTracker.kt @@ -18,10 +18,10 @@ package im.vector.app.features.home.room.detail.timeline.helper import android.os.Handler import android.os.Looper -import dagger.hilt.android.scopes.ActivityScoped import javax.inject.Inject +import javax.inject.Singleton -@ActivityScoped +@Singleton class VoiceMessagePlaybackTracker @Inject constructor() { private val mainHandler = Handler(Looper.getMainLooper()) diff --git a/vector/src/main/java/im/vector/app/features/voice/AbstractVoiceRecorder.kt b/vector/src/main/java/im/vector/app/features/voice/AbstractVoiceRecorder.kt index 1a91c00e11d..c07dde5aeb9 100644 --- a/vector/src/main/java/im/vector/app/features/voice/AbstractVoiceRecorder.kt +++ b/vector/src/main/java/im/vector/app/features/voice/AbstractVoiceRecorder.kt @@ -18,9 +18,13 @@ package im.vector.app.features.voice import android.content.Context import android.media.MediaRecorder +import android.net.Uri import android.os.Build +import org.matrix.android.sdk.api.session.content.ContentAttachmentData +import org.matrix.android.sdk.internal.util.md5 import java.io.File import java.io.FileOutputStream +import java.util.UUID abstract class AbstractVoiceRecorder( private val context: Context, @@ -57,9 +61,17 @@ abstract class AbstractVoiceRecorder( } } - override fun startRecord() { + override fun initializeRecord(attachmentData: ContentAttachmentData) { + outputFile = attachmentData.findVoiceFile(outputDirectory) + } + + override fun startRecord(roomId: String) { init() - outputFile = File(outputDirectory, "Voice message.$filenameExt") + val fileName = "${UUID.randomUUID()}.$filenameExt" + val outputDirectoryForRoom = File(outputDirectory, roomId.md5()).apply { + mkdirs() + } + outputFile = File(outputDirectoryForRoom, fileName) val mr = mediaRecorder ?: return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -100,3 +112,11 @@ abstract class AbstractVoiceRecorder( return convertFile(outputFile) } } + +private fun ContentAttachmentData.findVoiceFile(baseDirectory: File): File { + return File(baseDirectory, queryUri.takePathAfter(baseDirectory.name)) +} + +private fun Uri.takePathAfter(after: String): String { + return pathSegments.takeLastWhile { it != after }.joinToString(separator = "/") { it } +} diff --git a/vector/src/main/java/im/vector/app/features/voice/VoiceRecorder.kt b/vector/src/main/java/im/vector/app/features/voice/VoiceRecorder.kt index 17e70997b21..691e064b8f1 100644 --- a/vector/src/main/java/im/vector/app/features/voice/VoiceRecorder.kt +++ b/vector/src/main/java/im/vector/app/features/voice/VoiceRecorder.kt @@ -16,13 +16,21 @@ package im.vector.app.features.voice +import org.matrix.android.sdk.api.session.content.ContentAttachmentData import java.io.File interface VoiceRecorder { + /** + * Initialize recording with a pre-recorded file. + * @param attachmentData data of the recorded file + */ + fun initializeRecord(attachmentData: ContentAttachmentData) + /** * Start the recording + * @param roomId id of the room to start record */ - fun startRecord() + fun startRecord(roomId: String) /** * Stop the recording