Voice message playback implementation.

This commit is contained in:
Onuray Sahin 2021-07-01 10:49:04 +03:00
parent 5676226f42
commit 9d48b399df
14 changed files with 808 additions and 72 deletions

View File

@ -35,7 +35,8 @@ data class ContentAttachmentData(
val name: String? = null, val name: String? = null,
val queryUri: Uri, val queryUri: Uri,
val mimeType: String?, val mimeType: String?,
val type: Type val type: Type,
val waveform: List<Int>? = null
) : Parcelable { ) : Parcelable {
@JsonClass(generateAdapter = false) @JsonClass(generateAdapter = false)

View File

@ -22,7 +22,7 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class AudioWaveformInfo( data class AudioWaveformInfo(
@Json(name = "duration") @Json(name = "duration")
val duration: Long? = null, val duration: Int? = null,
@Json(name = "waveform") @Json(name = "waveform")
val waveform: List<Int>? = null val waveform: List<Int>? = null

View File

@ -300,8 +300,8 @@ internal class LocalEchoEventFactory @Inject constructor(
), ),
url = attachment.queryUri.toString(), url = attachment.queryUri.toString(),
audioWaveformInfo = if (!isVoiceMessage) null else AudioWaveformInfo( audioWaveformInfo = if (!isVoiceMessage) null else AudioWaveformInfo(
duration = attachment.duration, duration = attachment.duration?.toInt(),
waveform = null // TODO. waveform = attachment.waveform
), ),
voiceMessageIndicator = if (!isVoiceMessage) null else Any() voiceMessageIndicator = if (!isVoiceMessage) null else Any()
) )

View File

@ -23,5 +23,6 @@ data class MultiPickerAudioType(
override val size: Long, override val size: Long,
override val mimeType: String?, override val mimeType: String?,
override val contentUri: Uri, override val contentUri: Uri,
val duration: Long val duration: Long,
var waveform: List<Int>? = null
) : MultiPickerBaseType ) : MultiPickerBaseType

View File

@ -57,7 +57,8 @@ fun MultiPickerAudioType.toContentAttachmentData(): ContentAttachmentData {
size = size, size = size,
name = displayName, name = displayName,
duration = duration, duration = duration,
queryUri = contentUri queryUri = contentUri,
waveform = waveform
) )
} }

View File

@ -21,6 +21,7 @@ import android.view.View
import im.vector.app.core.platform.VectorViewModelAction import im.vector.app.core.platform.VectorViewModelAction
import org.matrix.android.sdk.api.session.content.ContentAttachmentData import org.matrix.android.sdk.api.session.content.ContentAttachmentData
import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent
import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent
import org.matrix.android.sdk.api.session.room.model.message.MessageWithAttachmentContent import org.matrix.android.sdk.api.session.room.model.message.MessageWithAttachmentContent
import org.matrix.android.sdk.api.session.room.timeline.Timeline import org.matrix.android.sdk.api.session.room.timeline.Timeline
@ -112,5 +113,9 @@ sealed class RoomDetailAction : VectorViewModelAction {
// Voice Message // Voice Message
object StartRecordingVoiceMessage : RoomDetailAction() object StartRecordingVoiceMessage : RoomDetailAction()
data class EndRecordingVoiceMessage(val recordTime: Long) : RoomDetailAction() data class EndRecordingVoiceMessage(val isCancelled: Boolean) : RoomDetailAction()
object PauseRecordingVoiceMessage : RoomDetailAction()
data class PlayOrPauseVoicePlayback(val eventId: String, val messageAudioContent: MessageAudioContent) : RoomDetailAction()
object PlayOrPauseRecordingPlayback : RoomDetailAction()
object EndAllVoiceActions : RoomDetailAction()
} }

View File

@ -132,6 +132,7 @@ import im.vector.app.features.crypto.keysbackup.restore.KeysBackupRestoreActivit
import im.vector.app.features.crypto.verification.VerificationBottomSheet import im.vector.app.features.crypto.verification.VerificationBottomSheet
import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.room.detail.composer.TextComposerView import im.vector.app.features.home.room.detail.composer.TextComposerView
import im.vector.app.features.home.room.detail.composer.VoiceMessageRecorderView
import im.vector.app.features.home.room.detail.readreceipts.DisplayReadReceiptsBottomSheet import im.vector.app.features.home.room.detail.readreceipts.DisplayReadReceiptsBottomSheet
import im.vector.app.features.home.room.detail.timeline.TimelineEventController import im.vector.app.features.home.room.detail.timeline.TimelineEventController
import im.vector.app.features.home.room.detail.timeline.action.EventSharedAction import im.vector.app.features.home.room.detail.timeline.action.EventSharedAction
@ -139,6 +140,7 @@ import im.vector.app.features.home.room.detail.timeline.action.MessageActionsBot
import im.vector.app.features.home.room.detail.timeline.action.MessageSharedActionViewModel import im.vector.app.features.home.room.detail.timeline.action.MessageSharedActionViewModel
import im.vector.app.features.home.room.detail.timeline.edithistory.ViewEditHistoryBottomSheet import im.vector.app.features.home.room.detail.timeline.edithistory.ViewEditHistoryBottomSheet
import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider
import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
import im.vector.app.features.home.room.detail.timeline.image.buildImageContentRendererData import im.vector.app.features.home.room.detail.timeline.image.buildImageContentRendererData
import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem
import im.vector.app.features.home.room.detail.timeline.item.MessageFileItem import im.vector.app.features.home.room.detail.timeline.item.MessageFileItem
@ -185,6 +187,7 @@ import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.Membership
import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.model.RoomSummary
import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent
import org.matrix.android.sdk.api.session.room.model.message.MessageContent import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessageFormat import org.matrix.android.sdk.api.session.room.model.message.MessageFormat
import org.matrix.android.sdk.api.session.room.model.message.MessageImageInfoContent import org.matrix.android.sdk.api.session.room.model.message.MessageImageInfoContent
@ -235,7 +238,8 @@ class RoomDetailFragment @Inject constructor(
private val imageContentRenderer: ImageContentRenderer, private val imageContentRenderer: ImageContentRenderer,
private val roomDetailPendingActionStore: RoomDetailPendingActionStore, private val roomDetailPendingActionStore: RoomDetailPendingActionStore,
private val pillsPostProcessorFactory: PillsPostProcessor.Factory, private val pillsPostProcessorFactory: PillsPostProcessor.Factory,
private val callManager: WebRtcCallManager private val callManager: WebRtcCallManager,
private val voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker
) : ) :
VectorBaseFragment<FragmentRoomDetailBinding>(), VectorBaseFragment<FragmentRoomDetailBinding>(),
TimelineEventController.Callback, TimelineEventController.Callback,
@ -334,6 +338,7 @@ class RoomDetailFragment @Inject constructor(
setupConfBannerView() setupConfBannerView()
setupEmojiPopup() setupEmojiPopup()
setupFailedMessagesWarningView() setupFailedMessagesWarningView()
setupVoiceMessageView()
views.roomToolbarContentView.debouncedClicks { views.roomToolbarContentView.debouncedClicks {
navigator.openRoomProfile(requireActivity(), roomDetailArgs.roomId) navigator.openRoomProfile(requireActivity(), roomDetailArgs.roomId)
@ -585,6 +590,33 @@ class RoomDetailFragment @Inject constructor(
} }
} }
private fun setupVoiceMessageView() {
views.voiceMessageRecorderView.voiceMessagePlaybackTracker = voiceMessagePlaybackTracker
views.voiceMessageRecorderView.callback = object : VoiceMessageRecorderView.Callback {
override fun onVoiceRecordingStarted() {
if (checkPermissions(PERMISSIONS_FOR_AUDIO_IP_CALL, requireActivity(), 0)) {
views.composerLayout.isInvisible = true
roomDetailViewModel.handle(RoomDetailAction.StartRecordingVoiceMessage)
context?.toast(R.string.voice_message_release_to_send_toast)
}
}
override fun onVoiceRecordingEnded(isCancelled: Boolean) {
views.composerLayout.isInvisible = false
roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled))
}
override fun onVoiceRecordingPlaybackModeOn() {
roomDetailViewModel.handle(RoomDetailAction.PauseRecordingVoiceMessage)
}
override fun onVoicePlaybackButtonClicked() {
roomDetailViewModel.handle(RoomDetailAction.PlayOrPauseRecordingPlayback)
}
}
}
private fun joinJitsiRoom(jitsiWidget: Widget, enableVideo: Boolean) { private fun joinJitsiRoom(jitsiWidget: Widget, enableVideo: Boolean) {
navigator.openRoomWidget(requireContext(), roomDetailArgs.roomId, jitsiWidget, mapOf(JitsiCallViewModel.ENABLE_VIDEO_OPTION to enableVideo)) navigator.openRoomWidget(requireContext(), roomDetailArgs.roomId, jitsiWidget, mapOf(JitsiCallViewModel.ENABLE_VIDEO_OPTION to enableVideo))
} }
@ -969,6 +1001,10 @@ class RoomDetailFragment @Inject constructor(
notificationDrawerManager.setCurrentRoom(null) notificationDrawerManager.setCurrentRoom(null)
roomDetailViewModel.handle(RoomDetailAction.SaveDraft(views.composerLayout.text.toString())) roomDetailViewModel.handle(RoomDetailAction.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.
roomDetailViewModel.handle(RoomDetailAction.EndAllVoiceActions)
views.voiceMessageRecorderView.initVoiceRecordingViews()
} }
private val attachmentFileActivityResultLauncher = registerStartForActivityResult { private val attachmentFileActivityResultLauncher = registerStartForActivityResult {
@ -1191,19 +1227,14 @@ class RoomDetailFragment @Inject constructor(
} }
override fun onTextEmptyStateChanged(isEmpty: Boolean) { override fun onTextEmptyStateChanged(isEmpty: Boolean) {
// No op views.voiceMessageRecorderView.isVisible = !views.composerLayout.views.sendButton.isVisible
} }
override fun onVoiceRecordingStarted() { override fun onTouchVoiceRecording() {
roomDetailViewModel.handle(RoomDetailAction.StartRecordingVoiceMessage) if (checkPermissions(PERMISSIONS_FOR_AUDIO_IP_CALL, requireActivity(), 0)) {
} views.composerLayout.isInvisible = true
views.voiceMessageRecorderView.isVisible = true
override fun onVoiceRecordingEnded(recordTime: Long) { }
roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(recordTime))
}
override fun checkVoiceRecordingPermission(): Boolean {
return checkPermissions(PERMISSIONS_FOR_AUDIO_IP_CALL, requireActivity(), 0)
} }
} }
} }
@ -1720,14 +1751,18 @@ class RoomDetailFragment @Inject constructor(
onUrlClicked(url, url) onUrlClicked(url, url)
} }
override fun onPreviewUrlCloseClicked(eventId: String, url: String) { override fun onPreviewUrlCloseClicked(eventId: String, fileUrl: String) {
roomDetailViewModel.handle(RoomDetailAction.DoNotShowPreviewUrlFor(eventId, url)) roomDetailViewModel.handle(RoomDetailAction.DoNotShowPreviewUrlFor(eventId, fileUrl))
} }
override fun onPreviewUrlImageClicked(sharedView: View?, mxcUrl: String?, title: String?) { override fun onPreviewUrlImageClicked(sharedView: View?, mxcUrl: String?, title: String?) {
navigator.openBigImageViewer(requireActivity(), sharedView, mxcUrl, title) navigator.openBigImageViewer(requireActivity(), sharedView, mxcUrl, title)
} }
override fun onVoiceControlButtonClicked(eventId: String, messageAudioContent: MessageAudioContent) {
roomDetailViewModel.handle(RoomDetailAction.PlayOrPauseVoicePlayback(eventId, messageAudioContent))
}
private fun onShareActionClicked(action: EventSharedAction.Share) { private fun onShareActionClicked(action: EventSharedAction.Share) {
if (action.messageContent is MessageTextContent) { if (action.messageContent is MessageTextContent) {
shareText(requireContext(), action.messageContent.body) shareText(requireContext(), action.messageContent.body)

View File

@ -18,7 +18,6 @@ package im.vector.app.features.home.room.detail
import android.net.Uri import android.net.Uri
import androidx.annotation.IdRes import androidx.annotation.IdRes
import androidx.core.net.toUri
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.airbnb.mvrx.Async import com.airbnb.mvrx.Async
import com.airbnb.mvrx.Fail import com.airbnb.mvrx.Fail
@ -49,7 +48,7 @@ import im.vector.app.features.command.ParsedCommand
import im.vector.app.features.createdirect.DirectRoomHelper import im.vector.app.features.createdirect.DirectRoomHelper
import im.vector.app.features.crypto.keysrequest.OutboundSessionKeySharingStrategy import im.vector.app.features.crypto.keysrequest.OutboundSessionKeySharingStrategy
import im.vector.app.features.crypto.verification.SupportedVerificationMethodsProvider import im.vector.app.features.crypto.verification.SupportedVerificationMethodsProvider
import im.vector.app.features.home.room.detail.composer.VoiceMessageRecordingHelper import im.vector.app.features.home.room.detail.composer.VoiceMessageHelper
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.sticker.StickerPickerActionHandler import im.vector.app.features.home.room.detail.sticker.StickerPickerActionHandler
import im.vector.app.features.home.room.detail.timeline.helper.RoomSummariesHolder import im.vector.app.features.home.room.detail.timeline.helper.RoomSummariesHolder
@ -59,7 +58,6 @@ import im.vector.app.features.home.room.typing.TypingHelper
import im.vector.app.features.powerlevel.PowerLevelsObservableFactory import im.vector.app.features.powerlevel.PowerLevelsObservableFactory
import im.vector.app.features.session.coroutineScope import im.vector.app.features.session.coroutineScope
import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.VectorPreferences
import im.vector.lib.multipicker.utils.toMultiPickerAudioType
import io.reactivex.Observable import io.reactivex.Observable
import io.reactivex.rxkotlin.subscribeBy import io.reactivex.rxkotlin.subscribeBy
import io.reactivex.schedulers.Schedulers import io.reactivex.schedulers.Schedulers
@ -123,7 +121,7 @@ class RoomDetailViewModel @AssistedInject constructor(
private val chatEffectManager: ChatEffectManager, private val chatEffectManager: ChatEffectManager,
private val directRoomHelper: DirectRoomHelper, private val directRoomHelper: DirectRoomHelper,
private val jitsiService: JitsiService, private val jitsiService: JitsiService,
private val voiceMessageRecordingHelper: VoiceMessageRecordingHelper, private val voiceMessageHelper: VoiceMessageHelper,
timelineSettingsFactory: TimelineSettingsFactory timelineSettingsFactory: TimelineSettingsFactory
) : VectorViewModel<RoomDetailViewState, RoomDetailAction, RoomDetailViewEvents>(initialState), ) : VectorViewModel<RoomDetailViewState, RoomDetailAction, RoomDetailViewEvents>(initialState),
Timeline.Listener, ChatEffectManager.Delegate, CallProtocolsChecker.Listener { Timeline.Listener, ChatEffectManager.Delegate, CallProtocolsChecker.Listener {
@ -321,7 +319,7 @@ class RoomDetailViewModel @AssistedInject constructor(
RoomDetailAction.QuickActionSetAvatar -> handleQuickSetAvatar() RoomDetailAction.QuickActionSetAvatar -> handleQuickSetAvatar()
is RoomDetailAction.SetAvatarAction -> handleSetNewAvatar(action) is RoomDetailAction.SetAvatarAction -> handleSetNewAvatar(action)
RoomDetailAction.QuickActionSetTopic -> _viewEvents.post(RoomDetailViewEvents.OpenRoomSettings) RoomDetailAction.QuickActionSetTopic -> _viewEvents.post(RoomDetailViewEvents.OpenRoomSettings)
is RoomDetailAction.ShowRoomAvatarFullScreen -> { is RoomDetailAction.ShowRoomAvatarFullScreen -> {
_viewEvents.post( _viewEvents.post(
RoomDetailViewEvents.ShowRoomAvatarFullScreen(action.matrixItem, action.transitionView) RoomDetailViewEvents.ShowRoomAvatarFullScreen(action.matrixItem, action.transitionView)
) )
@ -330,7 +328,11 @@ class RoomDetailViewModel @AssistedInject constructor(
RoomDetailAction.RemoveAllFailedMessages -> handleRemoveAllFailedMessages() RoomDetailAction.RemoveAllFailedMessages -> handleRemoveAllFailedMessages()
RoomDetailAction.ResendAll -> handleResendAll() RoomDetailAction.ResendAll -> handleResendAll()
RoomDetailAction.StartRecordingVoiceMessage -> handleStartRecordingVoiceMessage() RoomDetailAction.StartRecordingVoiceMessage -> handleStartRecordingVoiceMessage()
is RoomDetailAction.EndRecordingVoiceMessage -> handleEndRecordingVoiceMessage(action.recordTime) is RoomDetailAction.EndRecordingVoiceMessage -> handleEndRecordingVoiceMessage(action.isCancelled)
is RoomDetailAction.PlayOrPauseVoicePlayback -> handlePlayOrPauseVoicePlayback(action)
RoomDetailAction.PauseRecordingVoiceMessage -> handlePauseRecordingVoiceMessage()
RoomDetailAction.PlayOrPauseRecordingPlayback -> handlePlayOrPauseRecordingPlayback()
RoomDetailAction.EndAllVoiceActions -> handleEndAllVoiceActions()
}.exhaustive }.exhaustive
} }
@ -619,21 +621,39 @@ class RoomDetailViewModel @AssistedInject constructor(
} }
private fun handleStartRecordingVoiceMessage() { private fun handleStartRecordingVoiceMessage() {
voiceMessageRecordingHelper.startRecording() voiceMessageHelper.startRecording()
} }
private fun handleEndRecordingVoiceMessage(recordTime: Long) { private fun handleEndRecordingVoiceMessage(isCancelled: Boolean) {
if (recordTime == 0L) { if (isCancelled) {
voiceMessageRecordingHelper.deleteRecording() voiceMessageHelper.deleteRecording()
return return
} }
voiceMessageRecordingHelper.stopRecording(recordTime)?.let { audioType -> voiceMessageHelper.stopRecording()?.let { audioType ->
room.sendMedia(audioType.toContentAttachmentData(), false, emptySet()) room.sendMedia(audioType.toContentAttachmentData(), false, emptySet())
room
//voiceMessageRecordingHelper.deleteRecording() //voiceMessageRecordingHelper.deleteRecording()
} }
} }
private fun handlePlayOrPauseVoicePlayback(action: RoomDetailAction.PlayOrPauseVoicePlayback) {
viewModelScope.launch(Dispatchers.IO) {
val audioFile = session.fileService().downloadFile(action.messageAudioContent)
voiceMessageHelper.startOrPausePlayback(action.eventId, audioFile)
}
}
private fun handlePlayOrPauseRecordingPlayback() {
voiceMessageHelper.startOrPauseRecordingPlayback()
}
private fun handleEndAllVoiceActions() {
voiceMessageHelper.stopAllVoiceActions()
}
private fun handlePauseRecordingVoiceMessage() {
voiceMessageHelper.pauseRecording()
}
private fun isIntegrationEnabled() = session.integrationManagerService().isIntegrationEnabled() private fun isIntegrationEnabled() = session.integrationManagerService().isIntegrationEnabled()
fun isMenuItemVisible(@IdRes itemId: Int): Boolean = com.airbnb.mvrx.withState(this) { state -> fun isMenuItemVisible(@IdRes itemId: Int): Boolean = com.airbnb.mvrx.withState(this) { state ->

View File

@ -0,0 +1,216 @@
/*
* 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.home.room.detail.composer
import android.content.Context
import android.media.AudioAttributes
import android.media.MediaPlayer
import android.media.MediaRecorder
import androidx.core.content.FileProvider
import im.vector.app.BuildConfig
import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
import im.vector.lib.multipicker.entity.MultiPickerAudioType
import im.vector.lib.multipicker.utils.toMultiPickerAudioType
import org.matrix.android.sdk.api.extensions.orFalse
import timber.log.Timber
import java.io.File
import java.io.FileInputStream
import java.io.FileNotFoundException
import java.io.FileOutputStream
import java.lang.IllegalStateException
import java.lang.RuntimeException
import java.util.Timer
import java.util.TimerTask
import java.util.UUID
import javax.inject.Inject
/**
* Helper class to record audio for voice messages.
*/
class VoiceMessageHelper @Inject constructor(
private val context: Context,
private val playbackTracker: VoiceMessagePlaybackTracker
) {
private var mediaPlayer: MediaPlayer? = null
private lateinit var mediaRecorder: MediaRecorder
private val outputDirectory = File(context.cacheDir, "downloads")
private var outputFile: File? = null
private var lastRecordingFile: File? = null // In case of user pauses recording, plays another one in timeline
private val amplitudeList = mutableListOf<Int>()
private val amplitudeTimer = Timer()
private var amplitudeTimerTask: TimerTask? = null
private val playbackTimer = Timer()
private var playbackTimerTask: TimerTask? = null
init {
if (!outputDirectory.exists()) {
outputDirectory.mkdirs()
}
}
private fun refreshMediaRecorder() {
mediaRecorder = MediaRecorder().apply {
setAudioSource(MediaRecorder.AudioSource.DEFAULT)
setOutputFormat(MediaRecorder.OutputFormat.OGG)
setAudioEncoder(MediaRecorder.AudioEncoder.OPUS)
setAudioEncodingBitRate(24000)
setAudioSamplingRate(48000)
}
}
fun startRecording() {
stopPlayback()
playbackTracker.makeAllPlaybacksIdle()
outputFile = File(outputDirectory, UUID.randomUUID().toString() + ".ogg")
lastRecordingFile = outputFile
amplitudeList.clear()
FileOutputStream(outputFile).use { fos ->
refreshMediaRecorder()
mediaRecorder.setOutputFile(fos.fd)
mediaRecorder.prepare()
mediaRecorder.start()
startRecordingAmplitudes()
}
}
fun stopRecording(): MultiPickerAudioType? {
try {
stopRecordingAmplitudes()
releaseMediaRecorder()
} catch (e: RuntimeException) { // Usually thrown when the record is less than 1 second.
Timber.e(e, "Cannot stop media recorder!")
}
try {
outputFile?.let {
val outputFileUri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileProvider", it)
return outputFileUri
?.toMultiPickerAudioType(context)
?.apply {
waveform = amplitudeList
}
} ?: return null
} catch (e: FileNotFoundException) {
Timber.e(e, "Cannot stop voice recording")
return null
}
}
private fun releaseMediaRecorder() {
mediaRecorder.stop()
mediaRecorder.reset()
mediaRecorder.release()
}
fun pauseRecording() {
releaseMediaRecorder()
}
fun deleteRecording() {
outputFile?.delete()
}
fun startOrPauseRecordingPlayback() {
lastRecordingFile?.let {
startOrPausePlayback(VoiceMessagePlaybackTracker.RECORDING_ID, it)
}
}
fun startOrPausePlayback(id: String, file: File) {
stopPlayback()
if (playbackTracker.getPlaybackState(id) is VoiceMessagePlaybackTracker.Listener.State.Playing) {
playbackTracker.stopPlayback(id)
} else {
playbackTracker.startPlayback(id)
startPlayback(id, file)
}
}
private fun startPlayback(id: String, file: File) {
val currentPlaybackTime = playbackTracker.getPlaybackTime(id)
FileInputStream(file).use { fis ->
mediaPlayer = MediaPlayer().apply {
setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.setUsage(AudioAttributes.USAGE_MEDIA)
.build()
)
setDataSource(fis.fd)
prepare()
start()
seekTo(currentPlaybackTime)
}
}
startPlaybackTimer(id)
}
private fun stopPlayback() {
mediaPlayer?.stop()
stopPlaybackTimer()
}
private fun startRecordingAmplitudes() {
amplitudeTimerTask = object : TimerTask() {
override fun run() {
try {
val maxAmplitude = mediaRecorder.maxAmplitude
amplitudeList.add(maxAmplitude)
playbackTracker.updateCurrentRecording(VoiceMessagePlaybackTracker.RECORDING_ID, amplitudeList)
} catch (e: IllegalStateException) {
Timber.e(e, "Cannot get max amplitude. Amplitude recording timer will be stopped.")
amplitudeTimerTask?.cancel()
}
}
}
amplitudeTimer.scheduleAtFixedRate(amplitudeTimerTask, 0, 100)
}
private fun stopRecordingAmplitudes() {
amplitudeTimerTask?.cancel()
}
private fun startPlaybackTimer(id: String) {
playbackTimerTask = object : TimerTask() {
override fun run() {
if (mediaPlayer?.isPlaying.orFalse()) {
val currentPosition = mediaPlayer?.currentPosition ?: 0
playbackTracker.updateCurrentPlaybackTime(id, currentPosition)
} else {
playbackTracker.stopPlayback(id = id, rememberPlaybackTime = false)
}
}
}
playbackTimer.scheduleAtFixedRate(playbackTimerTask, 0, 1000)
}
private fun stopPlaybackTimer() {
playbackTimerTask?.cancel()
}
fun stopAllVoiceActions() {
stopRecording()
stopPlayback()
deleteRecording()
playbackTracker.clear()
}
}

View File

@ -17,15 +17,26 @@
package im.vector.app.features.home.room.detail.composer package im.vector.app.features.home.room.detail.composer
import android.content.Context import android.content.Context
import android.text.format.DateUtils
import android.util.AttributeSet import android.util.AttributeSet
import android.util.TypedValue
import android.view.MotionEvent
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.Px
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.devlomi.record_view.OnRecordListener import com.visualizer.amplitude.AudioRecordView
import im.vector.app.BuildConfig import im.vector.app.BuildConfig
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.utils.toast
import im.vector.app.databinding.ViewVoiceMessageRecorderBinding import im.vector.app.databinding.ViewVoiceMessageRecorderBinding
import org.matrix.android.sdk.api.extensions.orFalse import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
import timber.log.Timber
import java.util.Timer
import java.util.TimerTask
import kotlin.math.abs
/** /**
* Encapsulates the voice message recording view and animations. * Encapsulates the voice message recording view and animations.
@ -34,59 +45,320 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
context: Context, context: Context,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
defStyleAttr: Int = 0 defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr) { ) : ConstraintLayout(context, attrs, defStyleAttr), VoiceMessagePlaybackTracker.Listener {
interface Callback { interface Callback {
fun onVoiceRecordingStarted() fun onVoiceRecordingStarted()
fun onVoiceRecordingEnded(recordTime: Long) fun onVoiceRecordingEnded(isCancelled: Boolean)
fun checkVoiceRecordingPermission(): Boolean fun onVoiceRecordingPlaybackModeOn()
fun onVoicePlaybackButtonClicked()
} }
private val views: ViewVoiceMessageRecorderBinding private val views: ViewVoiceMessageRecorderBinding
var callback: Callback? = null var callback: Callback? = null
var voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker? = null
set(value) {
field = value
value?.track(VoiceMessagePlaybackTracker.RECORDING_ID, this)
}
private var recordingState: RecordingState = RecordingState.NONE
private var firstX: Float = 0f
private var firstY: Float = 0f
private var lastX: Float = 0f
private var lastY: Float = 0f
private var recordingTime: Int = 0
private var amplitudeList = emptyList<Int>()
private val recordingTimer = Timer()
private var recordingTimerTask: TimerTask? = null
init { init {
inflate(context, R.layout.view_voice_message_recorder, this) inflate(context, R.layout.view_voice_message_recorder, this)
views = ViewVoiceMessageRecorderBinding.bind(this) views = ViewVoiceMessageRecorderBinding.bind(this)
views.voiceMessageButton.setRecordView(views.voiceMessageRecordView) initVoiceRecordingViews()
views.voiceMessageRecordView.timeLimit = BuildConfig.VOICE_MESSAGE_DURATION_LIMIT_MS
views.voiceMessageRecordView.setRecordPermissionHandler { callback?.checkVoiceRecordingPermission().orFalse() }
views.voiceMessageRecordView.setOnRecordListener(object : OnRecordListener {
override fun onStart() {
onVoiceRecordingStarted()
}
override fun onCancel() {
onVoiceRecordingEnded(0)
}
override fun onFinish(recordTime: Long, limitReached: Boolean) {
onVoiceRecordingEnded(recordTime)
}
override fun onLessThanSecond() {
onVoiceRecordingEnded(0)
}
})
} }
private fun onVoiceRecordingStarted() { fun initVoiceRecordingViews() {
hideRecordingViews(animationDuration = 0)
stopRecordingTimer()
views.voiceMessageMicButton.isVisible = true
views.voiceMessageSendButton.isVisible = false
views.voiceMessageSendButton.setOnClickListener {
stopRecordingTimer()
hideRecordingViews(animationDuration = 0)
views.voiceMessageSendButton.isVisible = false
recordingState = RecordingState.NONE
callback?.onVoiceRecordingEnded(isCancelled = false)
}
views.voiceMessagePlaybackLayout.findViewById<ImageButton>(R.id.voiceMessageDeletePlayback).setOnClickListener {
stopRecordingTimer()
hideRecordingViews(animationDuration = 0)
views.voiceMessageSendButton.isVisible = false
recordingState = RecordingState.NONE
callback?.onVoiceRecordingEnded(isCancelled = true)
}
views.voiceMessagePlaybackLayout.findViewById<AudioRecordView>(R.id.voicePlaybackWaveform).setOnClickListener {
if (recordingState !== RecordingState.PLAYBACK) {
recordingState = RecordingState.PLAYBACK
showPlaybackViews()
}
}
views.voiceMessagePlaybackLayout.findViewById<ImageButton>(R.id.voicePlaybackControlButton).setOnClickListener {
callback?.onVoicePlaybackButtonClicked()
}
views.voiceMessageMicButton.setOnTouchListener { _, event ->
return@setOnTouchListener when (event.action) {
MotionEvent.ACTION_DOWN -> {
startRecordingTimer()
callback?.onVoiceRecordingStarted()
recordingState = RecordingState.STARTED
showRecordingViews()
firstX = event.rawX
firstY = event.rawY
lastX = firstX
lastY = firstY
true
}
MotionEvent.ACTION_UP -> {
if (recordingState != RecordingState.LOCKED) {
stopRecordingTimer()
val isCancelled = recordingState == RecordingState.NONE || recordingState == RecordingState.CANCELLED
callback?.onVoiceRecordingEnded(isCancelled)
recordingState = RecordingState.NONE
hideRecordingViews()
}
true
}
MotionEvent.ACTION_MOVE -> {
handleMoveAction(event)
true
}
else -> false
}
}
}
private fun handleMoveAction(event: MotionEvent) {
val currentX = event.rawX
val currentY = event.rawY
updateRecordingState(currentX, currentY)
when (recordingState) {
RecordingState.CANCELLING -> {
val translationAmount = currentX - firstX
views.voiceMessageMicButton.translationX = translationAmount
views.voiceMessageSlideToCancel.translationX = translationAmount
views.voiceMessageLockBackground.isVisible = false
views.voiceMessageLockImage.isVisible = false
views.voiceMessageLockArrow.isVisible = false
}
RecordingState.LOCKING -> {
views.voiceMessageLockImage.setImageResource(R.drawable.ic_voice_message_unlocked)
val translationAmount = currentY - firstY
views.voiceMessageMicButton.translationY = translationAmount
views.voiceMessageLockArrow.translationY = translationAmount
}
RecordingState.CANCELLED -> {
callback?.onVoiceRecordingEnded(true)
hideRecordingViews()
}
RecordingState.LOCKED -> {
views.voiceMessageLockImage.setImageResource(R.drawable.ic_voice_message_locked)
views.voiceMessageLockImage.postDelayed( {
showRecordingLockedViews()
}, 500)
}
RecordingState.STARTED -> {
showRecordingViews()
}
RecordingState.NONE -> Timber.d("VoiceMessageRecorderView shouldn't be in NONE state while moving.")
RecordingState.PLAYBACK -> Timber.d("VoiceMessageRecorderView shouldn't be in PLAYBACK state while moving.")
}
lastX = currentX
lastY = currentY
}
private fun updateRecordingState(currentX: Float, currentY: Float) {
val distanceX = abs(firstX - currentX)
val distanceY = abs(firstY - currentY)
if (recordingState == RecordingState.STARTED) { // Determine if cancelling or locking for the first move action.
if (currentX < firstX && distanceX > distanceY) {
recordingState = RecordingState.CANCELLING
} else if (currentY < firstY && distanceY > distanceX) {
recordingState = RecordingState.LOCKING
}
} else if (recordingState == RecordingState.CANCELLING) { // Check if cancelling conditions met, also check if it should be initial state
if (abs(currentX - firstX) < 10 && lastX < currentX) {
recordingState = RecordingState.STARTED
} else if (shouldCancelRecording()) {
recordingState = RecordingState.CANCELLED
}
} else if (recordingState == RecordingState.LOCKING) { // Check if locking conditions met, also check if it should be initial state
if (abs(currentY - firstY) < 10 && lastY < currentY) {
recordingState = RecordingState.STARTED
} else if (shouldLockRecording()) {
recordingState = RecordingState.LOCKED
}
}
}
private fun shouldCancelRecording(): Boolean {
return abs(views.voiceMessageTimer.x + views.voiceMessageTimer.width - views.voiceMessageSlideToCancel.x) < 10
}
private fun shouldLockRecording(): Boolean {
return abs(views.voiceMessageLockImage.y + views.voiceMessageLockImage.height - views.voiceMessageLockArrow.y) < 10
}
private fun startRecordingTimer() {
recordingTimerTask = object : TimerTask() {
override fun run() {
recordingTime++
showRecordingTimer()
showRecordingWaveform()
val timeDiffToRecordingLimit = BuildConfig.VOICE_MESSAGE_DURATION_LIMIT_MS - recordingTime * 1000
if (timeDiffToRecordingLimit <= 0) {
views.voiceMessageRecordingLayout.post {
callback?.onVoiceRecordingEnded(false)
recordingState = RecordingState.NONE
stopRecordingTimer()
hideRecordingViews(animationDuration = 0)
}
} else if (timeDiffToRecordingLimit in 10000..11000) {
views.voiceMessageRecordingLayout.post {
views.voiceMessageSendButton.isVisible = false
context.toast(context.getString(R.string.voice_message_n_seconds_warning_toast, (timeDiffToRecordingLimit/1000).toInt()))
}
}
}
}
recordingTimer.scheduleAtFixedRate(recordingTimerTask, 0, 1000)
}
private fun showRecordingTimer() {
val formattedTimerText = DateUtils.formatElapsedTime((recordingTime).toLong())
if (recordingState == RecordingState.LOCKED) {
views.voiceMessagePlaybackLayout.findViewById<TextView>(R.id.voicePlaybackTime).apply {
post {
text = formattedTimerText
}
}
} else {
views.voiceMessageTimer.post {
views.voiceMessageTimer.text = formattedTimerText
}
}
}
private fun showRecordingWaveform() {
val audioRecordView = views.voiceMessagePlaybackLayout.findViewById<AudioRecordView>(R.id.voicePlaybackWaveform)
audioRecordView.apply {
post {
recreate()
amplitudeList.toMutableList().forEach { amplitude ->
update(amplitude)
}
}
}
}
private fun stopRecordingTimer() {
recordingTimerTask?.cancel()
recordingTime = 0
}
private fun showRecordingViews() {
views.voiceMessageMicButton.setImageResource(R.drawable.ic_voice_mic_recording)
(views.voiceMessageMicButton.layoutParams as MarginLayoutParams).apply { setMargins(0, 0, 0, 0) }
views.voiceMessageLockBackground.isVisible = true views.voiceMessageLockBackground.isVisible = true
views.voiceMessageLockArrow.isVisible = true views.voiceMessageLockBackground.animate().setDuration(300).translationY(-dpToPx(148)).start()
views.voiceMessageLockImage.isVisible = true views.voiceMessageLockImage.isVisible = true
views.voiceMessageButton.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_voice_mic_recording)) views.voiceMessageLockImage.setImageResource(R.drawable.ic_voice_message_unlocked)
callback?.onVoiceRecordingStarted() views.voiceMessageLockImage.animate().setDuration(500).translationY(-dpToPx(148)).start()
views.voiceMessageLockArrow.isVisible = true
views.voiceMessageSlideToCancel.isVisible = true
views.voiceMessageTimerIndicator.isVisible = true
views.voiceMessageTimer.isVisible = true
views.voiceMessageSendButton.isVisible = false
} }
private fun onVoiceRecordingEnded(recordTime: Long) { fun hideRecordingViews(animationDuration: Int = 300) {
views.voiceMessageMicButton.setImageResource(R.drawable.ic_voice_mic)
views.voiceMessageMicButton.animate().translationX(0f).translationY(0f).setDuration(animationDuration.toLong()).start()
(views.voiceMessageMicButton.layoutParams as MarginLayoutParams).apply { setMargins(0, 0, dpToPx(12).toInt(), dpToPx(12).toInt()) }
views.voiceMessageLockBackground.isVisible = false views.voiceMessageLockBackground.isVisible = false
views.voiceMessageLockArrow.isVisible = false views.voiceMessageLockBackground.animate().translationY(dpToPx(0)).start()
views.voiceMessageLockImage.isVisible = false views.voiceMessageLockImage.isVisible = false
views.voiceMessageButton.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_voice_mic)) views.voiceMessageLockImage.animate().translationY(dpToPx(0)).start()
callback?.onVoiceRecordingEnded(recordTime) views.voiceMessageLockArrow.isVisible = false
views.voiceMessageLockArrow.animate().translationY(0f).start()
views.voiceMessageSlideToCancel.isVisible = false
views.voiceMessageSlideToCancel.animate().translationX(0f).translationY(0f).start()
views.voiceMessageTimerIndicator.isVisible = false
views.voiceMessageTimer.isVisible = false
views.voiceMessagePlaybackLayout.isVisible = false
}
private fun showRecordingLockedViews() {
hideRecordingViews(animationDuration = 0)
views.voiceMessagePlaybackLayout.isVisible = true
views.voiceMessagePlaybackLayout.findViewById<ImageView>(R.id.voiceMessagePlaybackTimerIndicator).isVisible = true
views.voiceMessagePlaybackLayout.findViewById<ImageView>(R.id.voicePlaybackControlButton).isVisible = false
views.voiceMessageSendButton.isVisible = true
}
private fun showPlaybackViews() {
views.voiceMessagePlaybackLayout.findViewById<ImageView>(R.id.voiceMessagePlaybackTimerIndicator).isVisible = false
views.voiceMessagePlaybackLayout.findViewById<ImageView>(R.id.voicePlaybackControlButton).isVisible = true
callback?.onVoiceRecordingPlaybackModeOn()
}
@Px
private fun dpToPx(dp: Int): Float {
return TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
dp.toFloat(),
resources.displayMetrics
)
}
enum class RecordingState {
NONE,
STARTED,
CANCELLING,
CANCELLED,
LOCKING,
LOCKED,
PLAYBACK
}
override fun onUpdate(state: VoiceMessagePlaybackTracker.Listener.State) {
when (state) {
is VoiceMessagePlaybackTracker.Listener.State.Recording -> {
this.amplitudeList = state.amplitudeList
}
is VoiceMessagePlaybackTracker.Listener.State.Playing -> {
views.voiceMessagePlaybackLayout.findViewById<ImageButton>(R.id.voicePlaybackControlButton)
.setImageResource(R.drawable.ic_voice_pause)
val formattedTimerText = DateUtils.formatElapsedTime((state.playbackTime / 1000).toLong())
views.voiceMessagePlaybackLayout.findViewById<TextView>(R.id.voicePlaybackTime)
.setText(formattedTimerText)
}
is VoiceMessagePlaybackTracker.Listener.State.Idle -> {
views.voiceMessagePlaybackLayout.findViewById<ImageButton>(R.id.voicePlaybackControlButton)
.setImageResource(R.drawable.ic_voice_play)
}
}
} }
} }

View File

@ -65,6 +65,7 @@ import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.Membership
import org.matrix.android.sdk.api.session.room.model.ReadReceipt import org.matrix.android.sdk.api.session.room.model.ReadReceipt
import org.matrix.android.sdk.api.session.room.model.RoomMemberContent import org.matrix.android.sdk.api.session.room.model.RoomMemberContent
import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent
import org.matrix.android.sdk.api.session.room.model.message.MessageImageInfoContent import org.matrix.android.sdk.api.session.room.model.message.MessageImageInfoContent
import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent
import org.matrix.android.sdk.api.session.room.timeline.Timeline import org.matrix.android.sdk.api.session.room.timeline.Timeline
@ -111,6 +112,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
// Introduce ViewModel scoped component (or Hilt?) // Introduce ViewModel scoped component (or Hilt?)
fun getPreviewUrlRetriever(): PreviewUrlRetriever fun getPreviewUrlRetriever(): PreviewUrlRetriever
fun onVoiceControlButtonClicked(eventId: String, messageAudioContent: MessageAudioContent)
} }
interface ReactionPillCallback { interface ReactionPillCallback {

View File

@ -25,6 +25,7 @@ import android.text.style.ForegroundColorSpan
import android.view.View import android.view.View
import dagger.Lazy import dagger.Lazy
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.epoxy.ClickListener
import im.vector.app.core.epoxy.VectorEpoxyModel import im.vector.app.core.epoxy.VectorEpoxyModel
import im.vector.app.core.files.LocalFilesHelper import im.vector.app.core.files.LocalFilesHelper
import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.ColorProvider
@ -38,6 +39,7 @@ import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStat
import im.vector.app.features.home.room.detail.timeline.helper.MessageInformationDataFactory import im.vector.app.features.home.room.detail.timeline.helper.MessageInformationDataFactory
import im.vector.app.features.home.room.detail.timeline.helper.MessageItemAttributesFactory import im.vector.app.features.home.room.detail.timeline.helper.MessageItemAttributesFactory
import im.vector.app.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider import im.vector.app.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem
import im.vector.app.features.home.room.detail.timeline.item.MessageBlockCodeItem import im.vector.app.features.home.room.detail.timeline.item.MessageBlockCodeItem
import im.vector.app.features.home.room.detail.timeline.item.MessageBlockCodeItem_ import im.vector.app.features.home.room.detail.timeline.item.MessageBlockCodeItem_
@ -50,6 +52,8 @@ import im.vector.app.features.home.room.detail.timeline.item.MessageOptionsItem_
import im.vector.app.features.home.room.detail.timeline.item.MessagePollItem_ import im.vector.app.features.home.room.detail.timeline.item.MessagePollItem_
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.MessageTextItem_ import im.vector.app.features.home.room.detail.timeline.item.MessageTextItem_
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.RedactedMessageItem import im.vector.app.features.home.room.detail.timeline.item.RedactedMessageItem
import im.vector.app.features.home.room.detail.timeline.item.RedactedMessageItem_ import im.vector.app.features.home.room.detail.timeline.item.RedactedMessageItem_
import im.vector.app.features.home.room.detail.timeline.item.VerificationRequestItem import im.vector.app.features.home.room.detail.timeline.item.VerificationRequestItem
@ -110,7 +114,8 @@ class MessageItemFactory @Inject constructor(
private val avatarSizeProvider: AvatarSizeProvider, private val avatarSizeProvider: AvatarSizeProvider,
private val pillsPostProcessorFactory: PillsPostProcessor.Factory, private val pillsPostProcessorFactory: PillsPostProcessor.Factory,
private val spanUtils: SpanUtils, private val spanUtils: SpanUtils,
private val session: Session) { private val session: Session,
private val voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker) {
// TODO inject this properly? // TODO inject this properly?
private var roomId: String = "" private var roomId: String = ""
@ -154,7 +159,13 @@ class MessageItemFactory @Inject constructor(
is MessageNoticeContent -> buildNoticeMessageItem(messageContent, informationData, highlight, callback, attributes) is MessageNoticeContent -> buildNoticeMessageItem(messageContent, informationData, highlight, callback, attributes)
is MessageVideoContent -> buildVideoMessageItem(messageContent, informationData, highlight, callback, attributes) is MessageVideoContent -> buildVideoMessageItem(messageContent, informationData, highlight, callback, attributes)
is MessageFileContent -> buildFileMessageItem(messageContent, highlight, attributes) is MessageFileContent -> buildFileMessageItem(messageContent, highlight, attributes)
is MessageAudioContent -> buildAudioMessageItem(messageContent, informationData, highlight, attributes) is MessageAudioContent -> {
if (messageContent.voiceMessageIndicator != null) {
buildVoiceMessageItem(params, messageContent, informationData, highlight, attributes)
} else {
buildAudioMessageItem(messageContent, informationData, highlight, attributes)
}
}
is MessageVerificationRequestContent -> buildVerificationRequestMessageItem(messageContent, informationData, highlight, callback, attributes) is MessageVerificationRequestContent -> buildVerificationRequestMessageItem(messageContent, informationData, highlight, callback, attributes)
is MessageOptionsContent -> buildOptionsMessageItem(messageContent, informationData, highlight, callback, attributes) is MessageOptionsContent -> buildOptionsMessageItem(messageContent, informationData, highlight, callback, attributes)
is MessagePollResponseContent -> noticeItemFactory.create(params) is MessagePollResponseContent -> noticeItemFactory.create(params)
@ -223,6 +234,46 @@ class MessageItemFactory @Inject constructor(
.iconRes(R.drawable.ic_headphones) .iconRes(R.drawable.ic_headphones)
} }
private fun buildVoiceMessageItem(params: TimelineItemFactoryParams,
messageContent: MessageAudioContent,
@Suppress("UNUSED_PARAMETER")
informationData: MessageInformationData,
highlight: Boolean,
attributes: AbsMessageItem.Attributes): MessageVoiceItem? {
val fileUrl = messageContent.getFileUrl()?.let {
if (informationData.sentByMe && !informationData.sendState.isSent()) {
it
} else {
it.takeIf { it.startsWith("mxc://") }
}
} ?: ""
val playbackControlButtonClickListener: ClickListener = object : ClickListener {
override fun invoke(view: View) {
params.callback?.onVoiceControlButtonClicked(informationData.eventId, messageContent)
}
}
return MessageVoiceItem_()
.attributes(attributes)
.duration(messageContent.audioWaveformInfo?.duration ?: 0)
.waveform(messageContent.audioWaveformInfo?.waveform ?: emptyList())
.playbackControlButtonClickListener(playbackControlButtonClickListener)
.voiceMessagePlaybackTracker(voiceMessagePlaybackTracker)
.izLocalFile(localFilesHelper.isLocalFile(fileUrl))
.izDownloaded(session.fileService().isFileInCache(
fileUrl,
messageContent.getFileName(),
messageContent.mimeType,
messageContent.encryptedFileInfo?.toElementToDecrypt())
)
.mxcUrl(fileUrl)
.contentUploadStateTrackerBinder(contentUploadStateTrackerBinder)
.contentDownloadStateTrackerBinder(contentDownloadStateTrackerBinder)
.highlighted(highlight)
.leftGuideline(avatarSizeProvider.leftGuideline)
}
private fun buildVerificationRequestMessageItem(messageContent: MessageVerificationRequestContent, private fun buildVerificationRequestMessageItem(messageContent: MessageVerificationRequestContent,
@Suppress("UNUSED_PARAMETER") @Suppress("UNUSED_PARAMETER")
informationData: MessageInformationData, informationData: MessageInformationData,

View File

@ -23,6 +23,7 @@ import im.vector.app.core.resources.StringProvider
import me.gujun.android.span.span import me.gujun.android.span.span
import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent
import org.matrix.android.sdk.api.session.room.model.message.MessageOptionsContent import org.matrix.android.sdk.api.session.room.model.message.MessageOptionsContent
import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.api.session.room.model.message.MessageType
import org.matrix.android.sdk.api.session.room.model.message.OPTION_TYPE_BUTTONS import org.matrix.android.sdk.api.session.room.model.message.OPTION_TYPE_BUTTONS
@ -72,7 +73,11 @@ class DisplayableEventFormatter @Inject constructor(
return simpleFormat(senderName, stringProvider.getString(R.string.sent_an_image), appendAuthor) return simpleFormat(senderName, stringProvider.getString(R.string.sent_an_image), appendAuthor)
} }
MessageType.MSGTYPE_AUDIO -> { MessageType.MSGTYPE_AUDIO -> {
return simpleFormat(senderName, stringProvider.getString(R.string.sent_an_audio_file), appendAuthor) if ((messageContent as? MessageAudioContent)?.voiceMessageIndicator != null) {
return simpleFormat(senderName, stringProvider.getString(R.string.sent_a_voice_message), appendAuthor)
} else {
return simpleFormat(senderName, stringProvider.getString(R.string.sent_an_audio_file), appendAuthor)
}
} }
MessageType.MSGTYPE_VIDEO -> { MessageType.MSGTYPE_VIDEO -> {
return simpleFormat(senderName, stringProvider.getString(R.string.sent_a_video), appendAuthor) return simpleFormat(senderName, stringProvider.getString(R.string.sent_a_video), appendAuthor)

View File

@ -0,0 +1,126 @@
/*
* 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.home.room.detail.timeline.helper
import android.os.Handler
import android.os.Looper
import im.vector.app.core.di.ScreenScope
import javax.inject.Inject
@ScreenScope
class VoiceMessagePlaybackTracker @Inject constructor() {
private val mainHandler = Handler(Looper.getMainLooper())
private val listeners = mutableMapOf<String, Listener>()
private val states = mutableMapOf<String, Listener.State>()
fun track(id: String, listener: Listener) {
listeners[id] = listener
val currentState = states[id] ?: Listener.State.Idle(0)
mainHandler.post {
listener.onUpdate(currentState)
}
}
fun makeAllPlaybacksIdle() {
listeners.keys.forEach { key ->
val currentPlaybackTime = getPlaybackTime(key)
states[key] = Listener.State.Idle(currentPlaybackTime)
mainHandler.post {
listeners[key]?.onUpdate(Listener.State.Idle(currentPlaybackTime))
}
}
}
fun startPlayback(id: String) {
val currentPlaybackTime = getPlaybackTime(id)
val currentState = Listener.State.Playing(currentPlaybackTime)
states[id] = currentState
mainHandler.post {
listeners[id]?.onUpdate(currentState)
}
// Make active playback IDLE
states
.filter { it.key != id }
.filter { it.value is Listener.State.Playing }
.keys
.forEach { key ->
val playbackTime = getPlaybackTime(key)
val state = Listener.State.Idle(playbackTime)
states[key] = state
mainHandler.post {
listeners[key]?.onUpdate(state)
}
}
}
fun stopPlayback(id: String, rememberPlaybackTime: Boolean = true) {
val currentPlaybackTime = if (rememberPlaybackTime) getPlaybackTime(id) else 0
states[id] = Listener.State.Idle(currentPlaybackTime)
mainHandler.post {
listeners[id]?.onUpdate(states[id]!!)
}
}
fun updateCurrentPlaybackTime(id: String, time: Int) {
states[id] = Listener.State.Playing(time)
mainHandler.post {
listeners[id]?.onUpdate(states[id]!!)
}
}
fun updateCurrentRecording(id: String, amplitudeList: List<Int>) {
states[id] = Listener.State.Recording(amplitudeList)
mainHandler.post {
listeners[id]?.onUpdate(states[id]!!)
}
}
fun getPlaybackState(id: String) = states[id]
fun getPlaybackTime(id: String): Int {
return when (val state = states[id]) {
is Listener.State.Playing -> state.playbackTime
is Listener.State.Idle -> state.playbackTime
else -> 0
}
}
fun clear() {
listeners.forEach {
it.value.onUpdate(Listener.State.Idle(0))
}
listeners.clear()
states.clear()
}
companion object {
var RECORDING_ID = "RECORDING_ID"
}
interface Listener {
fun onUpdate(state: State)
sealed class State {
data class Idle(val playbackTime: Int): State()
data class Playing(val playbackTime: Int) : State()
data class Recording(val amplitudeList: List<Int>) : State()
}
}
}