Merge pull request #4558 from vector-im/feature/adm/voice-draft

Adding support for voice drafts
This commit is contained in:
Benoit Marty 2021-11-30 20:44:49 +01:00 committed by GitHub
commit 3a8fd42513
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 174 additions and 84 deletions

1
changelog.d/3922.feature Normal file
View File

@ -0,0 +1 @@
Voice messages: Persist drafts of voice messages when navigating between rooms

View File

@ -22,6 +22,7 @@ import androidx.exifinterface.media.ExifInterface
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.matrix.android.sdk.api.util.MimeTypes.normalizeMimeType import org.matrix.android.sdk.api.util.MimeTypes.normalizeMimeType
import org.matrix.android.sdk.internal.di.MoshiProvider
@Parcelize @Parcelize
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
@ -49,4 +50,14 @@ data class ContentAttachmentData(
} }
fun getSafeMimeType() = mimeType?.normalizeMimeType() 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)
}
}
} }

View File

@ -24,14 +24,15 @@ package org.matrix.android.sdk.api.session.room.send
* REPLY: draft of a reply of another message * REPLY: draft of a reply of another message
*/ */
sealed interface UserDraft { sealed interface UserDraft {
data class Regular(val text: String) : UserDraft data class Regular(val content: String) : UserDraft
data class Quote(val linkedEventId: String, val text: String) : UserDraft data class Quote(val linkedEventId: String, val content: String) : UserDraft
data class Edit(val linkedEventId: String, val text: String) : UserDraft data class Edit(val linkedEventId: String, val content: String) : UserDraft
data class Reply(val linkedEventId: String, val text: String) : UserDraft data class Reply(val linkedEventId: String, val content: String) : UserDraft
data class Voice(val content: String) : UserDraft
fun isValid(): Boolean { fun isValid(): Boolean {
return when (this) { return when (this) {
is Regular -> text.isNotBlank() is Regular -> content.isNotBlank()
else -> true else -> true
} }
} }

View File

@ -30,16 +30,18 @@ internal object DraftMapper {
DraftEntity.MODE_EDIT -> UserDraft.Edit(entity.linkedEventId, entity.content) DraftEntity.MODE_EDIT -> UserDraft.Edit(entity.linkedEventId, entity.content)
DraftEntity.MODE_QUOTE -> UserDraft.Quote(entity.linkedEventId, entity.content) DraftEntity.MODE_QUOTE -> UserDraft.Quote(entity.linkedEventId, entity.content)
DraftEntity.MODE_REPLY -> UserDraft.Reply(entity.linkedEventId, entity.content) DraftEntity.MODE_REPLY -> UserDraft.Reply(entity.linkedEventId, entity.content)
DraftEntity.MODE_VOICE -> UserDraft.Voice(entity.content)
else -> null else -> null
} ?: UserDraft.Regular("") } ?: UserDraft.Regular("")
} }
fun map(domain: UserDraft): DraftEntity { fun map(domain: UserDraft): DraftEntity {
return when (domain) { return when (domain) {
is UserDraft.Regular -> DraftEntity(content = domain.text, draftMode = DraftEntity.MODE_REGULAR, linkedEventId = "") is UserDraft.Regular -> DraftEntity(content = domain.content, draftMode = DraftEntity.MODE_REGULAR, linkedEventId = "")
is UserDraft.Edit -> DraftEntity(content = domain.text, draftMode = DraftEntity.MODE_EDIT, linkedEventId = domain.linkedEventId) is UserDraft.Edit -> DraftEntity(content = domain.content, draftMode = DraftEntity.MODE_EDIT, linkedEventId = domain.linkedEventId)
is UserDraft.Quote -> DraftEntity(content = domain.text, draftMode = DraftEntity.MODE_QUOTE, linkedEventId = domain.linkedEventId) is UserDraft.Quote -> DraftEntity(content = domain.content, draftMode = DraftEntity.MODE_QUOTE, linkedEventId = domain.linkedEventId)
is UserDraft.Reply -> DraftEntity(content = domain.text, draftMode = DraftEntity.MODE_REPLY, 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 = "")
} }
} }
} }

View File

@ -21,7 +21,6 @@ import io.realm.RealmObject
internal open class DraftEntity(var content: String = "", internal open class DraftEntity(var content: String = "",
var draftMode: String = MODE_REGULAR, var draftMode: String = MODE_REGULAR,
var linkedEventId: String = "" var linkedEventId: String = ""
) : RealmObject() { ) : RealmObject() {
companion object { companion object {
@ -29,5 +28,6 @@ internal open class DraftEntity(var content: String = "",
const val MODE_EDIT = "EDIT" const val MODE_EDIT = "EDIT"
const val MODE_REPLY = "REPLY" const val MODE_REPLY = "REPLY"
const val MODE_QUOTE = "QUOTE" const val MODE_QUOTE = "QUOTE"
const val MODE_VOICE = "VOICE"
} }
} }

View File

@ -396,6 +396,7 @@ class RoomDetailFragment @Inject constructor(
is SendMode.Edit -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_edit, R.string.edit, 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.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.Reply -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_reply, R.string.reply, mode.text)
is SendMode.Voice -> renderVoiceMessageMode(mode.text)
} }
} }
@ -471,6 +472,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) { private fun handleSendButtonVisibilityChanged(event: MessageComposerViewEvents.AnimateSendButtonVisibility) {
if (event.isVisible) { if (event.isVisible) {
views.voiceMessageRecorderView.isVisible = false views.voiceMessageRecorderView.isVisible = false
@ -507,7 +515,7 @@ class RoomDetailFragment @Inject constructor(
private fun onCannotRecord() { private fun onCannotRecord() {
// Update the UI, cancel the animation // Update the UI, cancel the animation
messageComposerViewModel.handle(MessageComposerAction.OnVoiceRecordingUiStateChanged(RecordingUiState.None)) messageComposerViewModel.handle(MessageComposerAction.OnVoiceRecordingUiStateChanged(RecordingUiState.Idle))
} }
private fun acceptIncomingCall(event: RoomDetailViewEvents.DisplayAndAcceptCall) { private fun acceptIncomingCall(event: RoomDetailViewEvents.DisplayAndAcceptCall) {
@ -701,7 +709,7 @@ class RoomDetailFragment @Inject constructor(
if (checkPermissions(PERMISSIONS_FOR_VOICE_MESSAGE, requireActivity(), permissionVoiceMessageLauncher)) { if (checkPermissions(PERMISSIONS_FOR_VOICE_MESSAGE, requireActivity(), permissionVoiceMessageLauncher)) {
messageComposerViewModel.handle(MessageComposerAction.StartRecordingVoiceMessage) messageComposerViewModel.handle(MessageComposerAction.StartRecordingVoiceMessage)
vibrate(requireContext()) vibrate(requireContext())
updateRecordingUiState(RecordingUiState.Started(clock.epochMillis())) updateRecordingUiState(RecordingUiState.Recording(clock.epochMillis()))
} }
} }
@ -711,11 +719,12 @@ class RoomDetailFragment @Inject constructor(
override fun onVoiceRecordingCancelled() { override fun onVoiceRecordingCancelled() {
messageComposerViewModel.handle(MessageComposerAction.EndRecordingVoiceMessage(isCancelled = true)) messageComposerViewModel.handle(MessageComposerAction.EndRecordingVoiceMessage(isCancelled = true))
updateRecordingUiState(RecordingUiState.Cancelled) vibrate(requireContext())
updateRecordingUiState(RecordingUiState.Idle)
} }
override fun onVoiceRecordingLocked() { override fun onVoiceRecordingLocked() {
val startedState = withState(messageComposerViewModel) { it.voiceRecordingUiState as? RecordingUiState.Started } val startedState = withState(messageComposerViewModel) { it.voiceRecordingUiState as? RecordingUiState.Recording }
val startTime = startedState?.recordingStartTimestamp ?: clock.epochMillis() val startTime = startedState?.recordingStartTimestamp ?: clock.epochMillis()
updateRecordingUiState(RecordingUiState.Locked(startTime)) updateRecordingUiState(RecordingUiState.Locked(startTime))
} }
@ -726,22 +735,22 @@ class RoomDetailFragment @Inject constructor(
override fun onSendVoiceMessage() { override fun onSendVoiceMessage() {
messageComposerViewModel.handle(MessageComposerAction.EndRecordingVoiceMessage(isCancelled = false)) messageComposerViewModel.handle(MessageComposerAction.EndRecordingVoiceMessage(isCancelled = false))
updateRecordingUiState(RecordingUiState.None) updateRecordingUiState(RecordingUiState.Idle)
} }
override fun onDeleteVoiceMessage() { override fun onDeleteVoiceMessage() {
messageComposerViewModel.handle(MessageComposerAction.EndRecordingVoiceMessage(isCancelled = true)) messageComposerViewModel.handle(MessageComposerAction.EndRecordingVoiceMessage(isCancelled = true))
updateRecordingUiState(RecordingUiState.None) updateRecordingUiState(RecordingUiState.Idle)
} }
override fun onRecordingLimitReached() { override fun onRecordingLimitReached() {
messageComposerViewModel.handle(MessageComposerAction.PauseRecordingVoiceMessage) messageComposerViewModel.handle(MessageComposerAction.PauseRecordingVoiceMessage)
updateRecordingUiState(RecordingUiState.Playback) updateRecordingUiState(RecordingUiState.Draft)
} }
override fun onRecordingWaveformClicked() { override fun onRecordingWaveformClicked() {
messageComposerViewModel.handle(MessageComposerAction.PauseRecordingVoiceMessage) messageComposerViewModel.handle(MessageComposerAction.PauseRecordingVoiceMessage)
updateRecordingUiState(RecordingUiState.Playback) updateRecordingUiState(RecordingUiState.Draft)
} }
private fun updateRecordingUiState(state: RecordingUiState) { private fun updateRecordingUiState(state: RecordingUiState) {
@ -1046,10 +1055,10 @@ class RoomDetailFragment @Inject constructor(
.show() .show()
} }
private fun renderRegularMode(text: String) { private fun renderRegularMode(content: String) {
autoCompleter.exitSpecialMode() autoCompleter.exitSpecialMode()
views.composerLayout.collapse() views.composerLayout.collapse()
views.composerLayout.setTextIfDifferent(text) views.composerLayout.setTextIfDifferent(content)
views.composerLayout.views.sendButton.contentDescription = getString(R.string.send) views.composerLayout.views.sendButton.contentDescription = getString(R.string.send)
} }
@ -1139,10 +1148,7 @@ class RoomDetailFragment @Inject constructor(
if (withState(messageComposerViewModel) { it.isVoiceRecording } && requireActivity().isChangingConfigurations) { if (withState(messageComposerViewModel) { it.isVoiceRecording } && requireActivity().isChangingConfigurations) {
// we're rotating, maintain any active recordings // we're rotating, maintain any active recordings
} else { } else {
messageComposerViewModel.handle(MessageComposerAction.SaveDraft(views.composerLayout.text.toString())) messageComposerViewModel.handle(MessageComposerAction.OnEntersBackground(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)
} }
} }

View File

@ -18,10 +18,10 @@ package im.vector.app.features.home.room.detail.composer
import im.vector.app.core.platform.VectorViewModelAction import im.vector.app.core.platform.VectorViewModelAction
import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView 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 import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent
sealed class MessageComposerAction : VectorViewModelAction { sealed class MessageComposerAction : VectorViewModelAction {
data class SaveDraft(val draft: String) : MessageComposerAction()
data class SendMessage(val text: CharSequence, val autoMarkdown: Boolean) : MessageComposerAction() data class SendMessage(val text: CharSequence, val autoMarkdown: Boolean) : MessageComposerAction()
data class EnterEditMode(val eventId: String, val text: String) : MessageComposerAction() data class EnterEditMode(val eventId: String, val text: String) : MessageComposerAction()
data class EnterQuoteMode(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 EnterRegularMode(val text: String, val fromSharing: Boolean) : MessageComposerAction()
data class UserIsTyping(val isTyping: Boolean) : MessageComposerAction() data class UserIsTyping(val isTyping: Boolean) : MessageComposerAction()
data class OnTextChanged(val text: CharSequence) : MessageComposerAction() data class OnTextChanged(val text: CharSequence) : MessageComposerAction()
data class OnEntersBackground(val composerText: String) : MessageComposerAction()
// Voice Message // Voice Message
data class InitializeVoiceRecorder(val attachmentData: ContentAttachmentData) : MessageComposerAction()
data class OnVoiceRecordingUiStateChanged(val uiState: VoiceMessageRecorderView.RecordingUiState) : MessageComposerAction() data class OnVoiceRecordingUiStateChanged(val uiState: VoiceMessageRecorderView.RecordingUiState) : MessageComposerAction()
object StartRecordingVoiceMessage : MessageComposerAction() object StartRecordingVoiceMessage : MessageComposerAction()
data class EndRecordingVoiceMessage(val isCancelled: Boolean) : MessageComposerAction() data class EndRecordingVoiceMessage(val isCancelled: Boolean) : MessageComposerAction()

View File

@ -31,6 +31,7 @@ import im.vector.app.features.command.CommandParser
import im.vector.app.features.command.ParsedCommand import im.vector.app.features.command.ParsedCommand
import im.vector.app.features.home.room.detail.ChatEffect import im.vector.app.features.home.room.detail.ChatEffect
import im.vector.app.features.home.room.detail.composer.rainbow.RainbowGenerator 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.home.room.detail.toMessageType
import im.vector.app.features.powerlevel.PowerLevelsFlowFactory import im.vector.app.features.powerlevel.PowerLevelsFlowFactory
import im.vector.app.features.session.coroutineScope import im.vector.app.features.session.coroutineScope
@ -42,6 +43,7 @@ import org.commonmark.parser.Parser
import org.commonmark.renderer.html.HtmlRenderer import org.commonmark.renderer.html.HtmlRenderer
import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.query.QueryStringValue
import org.matrix.android.sdk.api.session.Session 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.EventType
import org.matrix.android.sdk.api.session.events.model.toContent 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.events.model.toModel
@ -85,17 +87,18 @@ class MessageComposerViewModel @AssistedInject constructor(
is MessageComposerAction.EnterQuoteMode -> handleEnterQuoteMode(action) is MessageComposerAction.EnterQuoteMode -> handleEnterQuoteMode(action)
is MessageComposerAction.EnterRegularMode -> handleEnterRegularMode(action) is MessageComposerAction.EnterRegularMode -> handleEnterRegularMode(action)
is MessageComposerAction.EnterReplyMode -> handleEnterReplyMode(action) is MessageComposerAction.EnterReplyMode -> handleEnterReplyMode(action)
is MessageComposerAction.SaveDraft -> handleSaveDraft(action)
is MessageComposerAction.SendMessage -> handleSendMessage(action) is MessageComposerAction.SendMessage -> handleSendMessage(action)
is MessageComposerAction.UserIsTyping -> handleUserIsTyping(action) is MessageComposerAction.UserIsTyping -> handleUserIsTyping(action)
is MessageComposerAction.OnTextChanged -> handleOnTextChanged(action) is MessageComposerAction.OnTextChanged -> handleOnTextChanged(action)
is MessageComposerAction.OnVoiceRecordingUiStateChanged -> handleOnVoiceRecordingUiStateChanged(action) is MessageComposerAction.OnVoiceRecordingUiStateChanged -> handleOnVoiceRecordingUiStateChanged(action)
MessageComposerAction.StartRecordingVoiceMessage -> handleStartRecordingVoiceMessage() is MessageComposerAction.StartRecordingVoiceMessage -> handleStartRecordingVoiceMessage()
is MessageComposerAction.EndRecordingVoiceMessage -> handleEndRecordingVoiceMessage(action.isCancelled) is MessageComposerAction.EndRecordingVoiceMessage -> handleEndRecordingVoiceMessage(action.isCancelled)
is MessageComposerAction.PlayOrPauseVoicePlayback -> handlePlayOrPauseVoicePlayback(action) is MessageComposerAction.PlayOrPauseVoicePlayback -> handlePlayOrPauseVoicePlayback(action)
MessageComposerAction.PauseRecordingVoiceMessage -> handlePauseRecordingVoiceMessage() MessageComposerAction.PauseRecordingVoiceMessage -> handlePauseRecordingVoiceMessage()
MessageComposerAction.PlayOrPauseRecordingPlayback -> handlePlayOrPauseRecordingPlayback() MessageComposerAction.PlayOrPauseRecordingPlayback -> handlePlayOrPauseRecordingPlayback()
is MessageComposerAction.EndAllVoiceActions -> handleEndAllVoiceActions(action.deleteRecord) is MessageComposerAction.EndAllVoiceActions -> handleEndAllVoiceActions(action.deleteRecord)
is MessageComposerAction.InitializeVoiceRecorder -> handleInitializeVoiceRecorder(action.attachmentData)
is MessageComposerAction.OnEntersBackground -> handleEntersBackground(action.composerText)
} }
} }
@ -432,6 +435,9 @@ class MessageComposerViewModel @AssistedInject constructor(
popDraft() popDraft()
} }
} }
is SendMode.Voice -> {
// do nothing
}
}.exhaustive }.exhaustive
} }
} }
@ -455,22 +461,23 @@ class MessageComposerViewModel @AssistedInject constructor(
copy( copy(
// Create a sendMode from a draft and retrieve the TimelineEvent // Create a sendMode from a draft and retrieve the TimelineEvent
sendMode = when (currentDraft) { sendMode = when (currentDraft) {
is UserDraft.Regular -> SendMode.Regular(currentDraft.text, false) is UserDraft.Regular -> SendMode.Regular(currentDraft.content, false)
is UserDraft.Quote -> { is UserDraft.Quote -> {
room.getTimeLineEvent(currentDraft.linkedEventId)?.let { timelineEvent -> 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 -> 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 -> 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 else -> null
} ?: SendMode.Regular("", fromSharing = false) } ?: SendMode.Regular("", fromSharing = false)
) )
@ -675,24 +682,24 @@ class MessageComposerViewModel @AssistedInject constructor(
/** /**
* Convert a send mode to a draft and save the draft * 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 { session.coroutineScope.launch {
when { when {
it.sendMode is SendMode.Regular && !it.sendMode.fromSharing -> { it.sendMode is SendMode.Regular && !it.sendMode.fromSharing -> {
setState { copy(sendMode = it.sendMode.copy(action.draft)) } setState { copy(sendMode = it.sendMode.copy(text = draft)) }
room.saveDraft(UserDraft.Regular(action.draft)) room.saveDraft(UserDraft.Regular(draft))
} }
it.sendMode is SendMode.Reply -> { it.sendMode is SendMode.Reply -> {
setState { copy(sendMode = it.sendMode.copy(text = action.draft)) } setState { copy(sendMode = it.sendMode.copy(text = draft)) }
room.saveDraft(UserDraft.Reply(it.sendMode.timelineEvent.root.eventId!!, action.draft)) room.saveDraft(UserDraft.Reply(it.sendMode.timelineEvent.root.eventId!!, draft))
} }
it.sendMode is SendMode.Quote -> { it.sendMode is SendMode.Quote -> {
setState { copy(sendMode = it.sendMode.copy(text = action.draft)) } setState { copy(sendMode = it.sendMode.copy(text = draft)) }
room.saveDraft(UserDraft.Quote(it.sendMode.timelineEvent.root.eventId!!, action.draft)) room.saveDraft(UserDraft.Quote(it.sendMode.timelineEvent.root.eventId!!, draft))
} }
it.sendMode is SendMode.Edit -> { it.sendMode is SendMode.Edit -> {
setState { copy(sendMode = it.sendMode.copy(text = action.draft)) } setState { copy(sendMode = it.sendMode.copy(text = draft)) }
room.saveDraft(UserDraft.Edit(it.sendMode.timelineEvent.root.eventId!!, action.draft)) room.saveDraft(UserDraft.Edit(it.sendMode.timelineEvent.root.eventId!!, draft))
} }
} }
} }
@ -700,7 +707,7 @@ class MessageComposerViewModel @AssistedInject constructor(
private fun handleStartRecordingVoiceMessage() { private fun handleStartRecordingVoiceMessage() {
try { try {
voiceMessageHelper.startRecording() voiceMessageHelper.startRecording(room.roomId)
} catch (failure: Throwable) { } catch (failure: Throwable) {
_viewEvents.post(MessageComposerViewEvents.VoicePlaybackOrRecordingFailure(failure)) _viewEvents.post(MessageComposerViewEvents.VoicePlaybackOrRecordingFailure(failure))
} }
@ -719,6 +726,7 @@ class MessageComposerViewModel @AssistedInject constructor(
} }
} }
} }
handleEnterRegularMode(MessageComposerAction.EnterRegularMode(text = "", fromSharing = false))
} }
private fun handlePlayOrPauseVoicePlayback(action: MessageComposerAction.PlayOrPauseVoicePlayback) { private fun handlePlayOrPauseVoicePlayback(action: MessageComposerAction.PlayOrPauseVoicePlayback) {
@ -741,13 +749,35 @@ class MessageComposerViewModel @AssistedInject constructor(
} }
private fun handleEndAllVoiceActions(deleteRecord: Boolean) { private fun handleEndAllVoiceActions(deleteRecord: Boolean) {
voiceMessageHelper.clearTracker()
voiceMessageHelper.stopAllVoiceActions(deleteRecord) voiceMessageHelper.stopAllVoiceActions(deleteRecord)
} }
private fun handleInitializeVoiceRecorder(attachmentData: ContentAttachmentData) {
voiceMessageHelper.initializeRecorder(attachmentData)
setState { copy(voiceRecordingUiState = VoiceMessageRecorderView.RecordingUiState.Draft) }
}
private fun handlePauseRecordingVoiceMessage() { private fun handlePauseRecordingVoiceMessage() {
voiceMessageHelper.pauseRecording() 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) { private fun launchSlashCommandFlowSuspendable(block: suspend () -> Unit) {
_viewEvents.post(MessageComposerViewEvents.SlashCommandLoading) _viewEvents.post(MessageComposerViewEvents.SlashCommandLoading)
viewModelScope.launch { viewModelScope.launch {

View File

@ -40,6 +40,7 @@ sealed interface SendMode {
data class Quote(val timelineEvent: TimelineEvent, val text: String) : SendMode data class Quote(val timelineEvent: TimelineEvent, val text: String) : SendMode
data class Edit(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 Reply(val timelineEvent: TimelineEvent, val text: String) : SendMode
data class Voice(val text: String) : SendMode
} }
data class MessageComposerViewState( data class MessageComposerViewState(
@ -47,15 +48,14 @@ data class MessageComposerViewState(
val canSendMessage: Boolean = true, val canSendMessage: Boolean = true,
val isSendButtonVisible: Boolean = false, val isSendButtonVisible: Boolean = false,
val sendMode: SendMode = SendMode.Regular("", false), val sendMode: SendMode = SendMode.Regular("", false),
val voiceRecordingUiState: VoiceMessageRecorderView.RecordingUiState = VoiceMessageRecorderView.RecordingUiState.None val voiceRecordingUiState: VoiceMessageRecorderView.RecordingUiState = VoiceMessageRecorderView.RecordingUiState.Idle
) : MavericksState { ) : MavericksState {
val isVoiceRecording = when (voiceRecordingUiState) { val isVoiceRecording = when (voiceRecordingUiState) {
VoiceMessageRecorderView.RecordingUiState.None, VoiceMessageRecorderView.RecordingUiState.Idle -> false
VoiceMessageRecorderView.RecordingUiState.Cancelled,
VoiceMessageRecorderView.RecordingUiState.Playback -> false
is VoiceMessageRecorderView.RecordingUiState.Locked, is VoiceMessageRecorderView.RecordingUiState.Locked,
is VoiceMessageRecorderView.RecordingUiState.Started -> true VoiceMessageRecorderView.RecordingUiState.Draft,
is VoiceMessageRecorderView.RecordingUiState.Recording -> true
} }
val isVoiceMessageIdle = !isVoiceRecording val isVoiceMessageIdle = !isVoiceRecording

View File

@ -30,6 +30,7 @@ import im.vector.lib.multipicker.entity.MultiPickerAudioType
import im.vector.lib.multipicker.utils.toMultiPickerAudioType import im.vector.lib.multipicker.utils.toMultiPickerAudioType
import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.session.content.ContentAttachmentData
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
@ -52,13 +53,22 @@ class VoiceMessageHelper @Inject constructor(
private var amplitudeTicker: CountUpTimer? = null private var amplitudeTicker: CountUpTimer? = null
private var playbackTicker: 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() stopPlayback()
playbackTracker.makeAllPlaybacksIdle() playbackTracker.makeAllPlaybacksIdle()
amplitudeList.clear() amplitudeList.clear()
try { try {
voiceRecorder.startRecord() voiceRecorder.startRecord(roomId)
} catch (failure: Throwable) { } catch (failure: Throwable) {
Timber.e(failure, "Unable to start recording") Timber.e(failure, "Unable to start recording")
throw VoiceFailure.UnableToRecord(failure) throw VoiceFailure.UnableToRecord(failure)
@ -78,7 +88,8 @@ class VoiceMessageHelper @Inject constructor(
try { try {
voiceMessageFile?.let { voiceMessageFile?.let {
val outputFileUri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileProvider", it, "Voice message.${it.extension}") val outputFileUri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileProvider", it, "Voice message.${it.extension}")
return outputFileUri.toMultiPickerAudioType(context) return outputFileUri
.toMultiPickerAudioType(context)
?.apply { ?.apply {
waveform = if (amplitudeList.size < 50) { waveform = if (amplitudeList.size < 50) {
amplitudeList amplitudeList
@ -218,12 +229,16 @@ class VoiceMessageHelper @Inject constructor(
playbackTicker = null playbackTicker = null
} }
fun stopAllVoiceActions(deleteRecord: Boolean = true) { fun clearTracker() {
stopRecording() playbackTracker.clear()
}
fun stopAllVoiceActions(deleteRecord: Boolean = true): MultiPickerAudioType? {
val audioType = stopRecording()
stopPlayback() stopPlayback()
if (deleteRecord) { if (deleteRecord) {
deleteRecording() deleteRecording()
} }
playbackTracker.clear() return audioType
} }
} }

View File

@ -93,7 +93,14 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
override fun onSendVoiceMessage() = callback.onSendVoiceMessage() override fun onSendVoiceMessage() = callback.onSendVoiceMessage()
override fun onDeleteVoiceMessage() = callback.onDeleteVoiceMessage() override fun onDeleteVoiceMessage() = callback.onDeleteVoiceMessage()
override fun onWaveformClicked() = callback.onRecordingWaveformClicked() override fun onWaveformClicked() {
when (lastKnownState) {
RecordingUiState.Draft -> callback.onVoicePlaybackButtonClicked()
is RecordingUiState.Recording,
is RecordingUiState.Locked -> callback.onRecordingWaveformClicked()
}
}
override fun onVoicePlaybackButtonClicked() = callback.onVoicePlaybackButtonClicked() override fun onVoicePlaybackButtonClicked() = callback.onVoicePlaybackButtonClicked()
override fun onMicButtonDrag(nextDragStateCreator: (DraggingState) -> DraggingState) { override fun onMicButtonDrag(nextDragStateCreator: (DraggingState) -> DraggingState) {
onDrag(dragState, newDragState = nextDragStateCreator(dragState)) onDrag(dragState, newDragState = nextDragStateCreator(dragState))
@ -112,19 +119,15 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
fun render(recordingState: RecordingUiState) { fun render(recordingState: RecordingUiState) {
if (lastKnownState == recordingState) return if (lastKnownState == recordingState) return
when (recordingState) { when (recordingState) {
RecordingUiState.None -> { RecordingUiState.Idle -> {
reset() reset()
} }
is RecordingUiState.Started -> { is RecordingUiState.Recording -> {
startRecordingTicker(startFromLocked = false, startAt = recordingState.recordingStartTimestamp) startRecordingTicker(startFromLocked = false, startAt = recordingState.recordingStartTimestamp)
voiceMessageViews.renderToast(context.getString(R.string.voice_message_release_to_send_toast)) voiceMessageViews.renderToast(context.getString(R.string.voice_message_release_to_send_toast))
voiceMessageViews.showRecordingViews() voiceMessageViews.showRecordingViews()
dragState = DraggingState.Ready dragState = DraggingState.Ready
} }
RecordingUiState.Cancelled -> {
reset()
vibrate(context)
}
is RecordingUiState.Locked -> { is RecordingUiState.Locked -> {
if (lastKnownState == null) { if (lastKnownState == null) {
startRecordingTicker(startFromLocked = true, startAt = recordingState.recordingStartTimestamp) startRecordingTicker(startFromLocked = true, startAt = recordingState.recordingStartTimestamp)
@ -134,9 +137,9 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
voiceMessageViews.showRecordingLockedViews(recordingState) voiceMessageViews.showRecordingLockedViews(recordingState)
}, 500) }, 500)
} }
RecordingUiState.Playback -> { RecordingUiState.Draft -> {
stopRecordingTicker() stopRecordingTicker()
voiceMessageViews.showPlaybackViews() voiceMessageViews.showDraftViews()
} }
} }
lastKnownState = recordingState lastKnownState = recordingState
@ -220,11 +223,10 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
} }
sealed interface RecordingUiState { sealed interface RecordingUiState {
object None : RecordingUiState object Idle : RecordingUiState
data class Started(val recordingStartTimestamp: Long) : RecordingUiState data class Recording(val recordingStartTimestamp: Long) : RecordingUiState
object Cancelled : RecordingUiState
data class Locked(val recordingStartTimestamp: Long) : RecordingUiState data class Locked(val recordingStartTimestamp: Long) : RecordingUiState
object Playback : RecordingUiState object Draft : RecordingUiState
} }
sealed interface DraggingState { sealed interface DraggingState {

View File

@ -23,9 +23,11 @@ import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.doOnLayout
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import com.visualizer.amplitude.AudioRecordView
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.extensions.setAttributeBackground import im.vector.app.core.extensions.setAttributeBackground
import im.vector.app.core.extensions.setAttributeTintedBackground 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. // Hide toasts if user cancelled recording before the timeout of the toast.
if (recordingState == RecordingUiState.Cancelled || recordingState == RecordingUiState.None) { if (recordingState == RecordingUiState.Idle) {
hideToast() hideToast()
} }
} }
@ -258,6 +260,16 @@ class VoiceMessageViews(
views.voiceMessageToast.isVisible = false 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) { fun showRecordingLockedViews(recordingState: RecordingUiState) {
hideRecordingViews(recordingState) hideRecordingViews(recordingState)
views.voiceMessagePlaybackLayout.isVisible = true views.voiceMessagePlaybackLayout.isVisible = true
@ -268,14 +280,8 @@ class VoiceMessageViews(
renderToast(resources.getString(R.string.voice_message_tap_to_stop_toast)) 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() { fun initViews() {
hideRecordingViews(RecordingUiState.None) hideRecordingViews(RecordingUiState.Idle)
views.voiceMessageMicButton.isVisible = true views.voiceMessageMicButton.isVisible = true
views.voiceMessageSendButton.isVisible = false views.voiceMessageSendButton.isVisible = false
views.voicePlaybackWaveform.post { views.voicePlaybackWaveform.recreate() } views.voicePlaybackWaveform.post { views.voicePlaybackWaveform.recreate() }
@ -320,11 +326,9 @@ class VoiceMessageViews(
} }
fun renderRecordingWaveform(amplitudeList: Array<Int>) { fun renderRecordingWaveform(amplitudeList: Array<Int>) {
views.voicePlaybackWaveform.post { views.voicePlaybackWaveform.doOnLayout { waveFormView ->
views.voicePlaybackWaveform.apply {
amplitudeList.iterator().forEach { amplitudeList.iterator().forEach {
update(it) (waveFormView as AudioRecordView).update(it)
}
} }
} }
} }

View File

@ -21,6 +21,7 @@ import android.media.MediaRecorder
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import org.matrix.android.sdk.api.session.content.ContentAttachmentData import org.matrix.android.sdk.api.session.content.ContentAttachmentData
import org.matrix.android.sdk.internal.util.md5
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.util.UUID import java.util.UUID
@ -60,9 +61,17 @@ abstract class AbstractVoiceRecorder(
} }
} }
override fun startRecord() { override fun initializeRecord(attachmentData: ContentAttachmentData) {
outputFile = attachmentData.findVoiceFile(outputDirectory)
}
override fun startRecord(roomId: String) {
init() init()
outputFile = File(outputDirectory, "${UUID.randomUUID()}.$filenameExt") val fileName = "${UUID.randomUUID()}.$filenameExt"
val outputDirectoryForRoom = File(outputDirectory, roomId.md5()).apply {
mkdirs()
}
outputFile = File(outputDirectoryForRoom, fileName)
val mr = mediaRecorder ?: return val mr = mediaRecorder ?: return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@ -104,7 +113,6 @@ abstract class AbstractVoiceRecorder(
} }
} }
@Suppress("UNUSED") // preemptively added for https://github.com/vector-im/element-android/pull/4527
private fun ContentAttachmentData.findVoiceFile(baseDirectory: File): File { private fun ContentAttachmentData.findVoiceFile(baseDirectory: File): File {
return File(baseDirectory, queryUri.takePathAfter(baseDirectory.name)) return File(baseDirectory, queryUri.takePathAfter(baseDirectory.name))
} }

View File

@ -16,13 +16,21 @@
package im.vector.app.features.voice package im.vector.app.features.voice
import org.matrix.android.sdk.api.session.content.ContentAttachmentData
import java.io.File import java.io.File
interface VoiceRecorder { interface VoiceRecorder {
/** /**
* Start the recording * Initialize recording with a pre-recorded file.
* @param attachmentData data of the recorded file
*/ */
fun startRecord() fun initializeRecord(attachmentData: ContentAttachmentData)
/**
* Start the recording
* @param roomId id of the room to start record
*/
fun startRecord(roomId: String)
/** /**
* Stop the recording * Stop the recording