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 queryUri: Uri,
val mimeType: String?,
val type: Type
val type: Type,
val waveform: List<Int>? = null
) : Parcelable {
@JsonClass(generateAdapter = false)

View File

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

View File

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

View File

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

View File

@ -57,7 +57,8 @@ fun MultiPickerAudioType.toContentAttachmentData(): ContentAttachmentData {
size = size,
name = displayName,
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 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.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.MessageWithAttachmentContent
import org.matrix.android.sdk.api.session.room.timeline.Timeline
@ -112,5 +113,9 @@ sealed class RoomDetailAction : VectorViewModelAction {
// Voice Message
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.home.AvatarRenderer
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.timeline.TimelineEventController
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.edithistory.ViewEditHistoryBottomSheet
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.item.AbsMessageItem
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.room.model.Membership
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.MessageFormat
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 roomDetailPendingActionStore: RoomDetailPendingActionStore,
private val pillsPostProcessorFactory: PillsPostProcessor.Factory,
private val callManager: WebRtcCallManager
private val callManager: WebRtcCallManager,
private val voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker
) :
VectorBaseFragment<FragmentRoomDetailBinding>(),
TimelineEventController.Callback,
@ -334,6 +338,7 @@ class RoomDetailFragment @Inject constructor(
setupConfBannerView()
setupEmojiPopup()
setupFailedMessagesWarningView()
setupVoiceMessageView()
views.roomToolbarContentView.debouncedClicks {
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) {
navigator.openRoomWidget(requireContext(), roomDetailArgs.roomId, jitsiWidget, mapOf(JitsiCallViewModel.ENABLE_VIDEO_OPTION to enableVideo))
}
@ -969,6 +1001,10 @@ class RoomDetailFragment @Inject constructor(
notificationDrawerManager.setCurrentRoom(null)
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 {
@ -1191,19 +1227,14 @@ class RoomDetailFragment @Inject constructor(
}
override fun onTextEmptyStateChanged(isEmpty: Boolean) {
// No op
views.voiceMessageRecorderView.isVisible = !views.composerLayout.views.sendButton.isVisible
}
override fun onVoiceRecordingStarted() {
roomDetailViewModel.handle(RoomDetailAction.StartRecordingVoiceMessage)
}
override fun onVoiceRecordingEnded(recordTime: Long) {
roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(recordTime))
}
override fun checkVoiceRecordingPermission(): Boolean {
return checkPermissions(PERMISSIONS_FOR_AUDIO_IP_CALL, requireActivity(), 0)
override fun onTouchVoiceRecording() {
if (checkPermissions(PERMISSIONS_FOR_AUDIO_IP_CALL, requireActivity(), 0)) {
views.composerLayout.isInvisible = true
views.voiceMessageRecorderView.isVisible = true
}
}
}
}
@ -1720,14 +1751,18 @@ class RoomDetailFragment @Inject constructor(
onUrlClicked(url, url)
}
override fun onPreviewUrlCloseClicked(eventId: String, url: String) {
roomDetailViewModel.handle(RoomDetailAction.DoNotShowPreviewUrlFor(eventId, url))
override fun onPreviewUrlCloseClicked(eventId: String, fileUrl: String) {
roomDetailViewModel.handle(RoomDetailAction.DoNotShowPreviewUrlFor(eventId, fileUrl))
}
override fun onPreviewUrlImageClicked(sharedView: View?, mxcUrl: String?, title: String?) {
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) {
if (action.messageContent is MessageTextContent) {
shareText(requireContext(), action.messageContent.body)

View File

@ -18,7 +18,6 @@ package im.vector.app.features.home.room.detail
import android.net.Uri
import androidx.annotation.IdRes
import androidx.core.net.toUri
import androidx.lifecycle.viewModelScope
import com.airbnb.mvrx.Async
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.crypto.keysrequest.OutboundSessionKeySharingStrategy
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.sticker.StickerPickerActionHandler
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.session.coroutineScope
import im.vector.app.features.settings.VectorPreferences
import im.vector.lib.multipicker.utils.toMultiPickerAudioType
import io.reactivex.Observable
import io.reactivex.rxkotlin.subscribeBy
import io.reactivex.schedulers.Schedulers
@ -123,7 +121,7 @@ class RoomDetailViewModel @AssistedInject constructor(
private val chatEffectManager: ChatEffectManager,
private val directRoomHelper: DirectRoomHelper,
private val jitsiService: JitsiService,
private val voiceMessageRecordingHelper: VoiceMessageRecordingHelper,
private val voiceMessageHelper: VoiceMessageHelper,
timelineSettingsFactory: TimelineSettingsFactory
) : VectorViewModel<RoomDetailViewState, RoomDetailAction, RoomDetailViewEvents>(initialState),
Timeline.Listener, ChatEffectManager.Delegate, CallProtocolsChecker.Listener {
@ -321,7 +319,7 @@ class RoomDetailViewModel @AssistedInject constructor(
RoomDetailAction.QuickActionSetAvatar -> handleQuickSetAvatar()
is RoomDetailAction.SetAvatarAction -> handleSetNewAvatar(action)
RoomDetailAction.QuickActionSetTopic -> _viewEvents.post(RoomDetailViewEvents.OpenRoomSettings)
is RoomDetailAction.ShowRoomAvatarFullScreen -> {
is RoomDetailAction.ShowRoomAvatarFullScreen -> {
_viewEvents.post(
RoomDetailViewEvents.ShowRoomAvatarFullScreen(action.matrixItem, action.transitionView)
)
@ -330,7 +328,11 @@ class RoomDetailViewModel @AssistedInject constructor(
RoomDetailAction.RemoveAllFailedMessages -> handleRemoveAllFailedMessages()
RoomDetailAction.ResendAll -> handleResendAll()
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
}
@ -619,21 +621,39 @@ class RoomDetailViewModel @AssistedInject constructor(
}
private fun handleStartRecordingVoiceMessage() {
voiceMessageRecordingHelper.startRecording()
voiceMessageHelper.startRecording()
}
private fun handleEndRecordingVoiceMessage(recordTime: Long) {
if (recordTime == 0L) {
voiceMessageRecordingHelper.deleteRecording()
private fun handleEndRecordingVoiceMessage(isCancelled: Boolean) {
if (isCancelled) {
voiceMessageHelper.deleteRecording()
return
}
voiceMessageRecordingHelper.stopRecording(recordTime)?.let { audioType ->
voiceMessageHelper.stopRecording()?.let { audioType ->
room.sendMedia(audioType.toContentAttachmentData(), false, emptySet())
room
//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()
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
import android.content.Context
import android.text.format.DateUtils
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.core.content.ContextCompat
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.R
import im.vector.app.core.utils.toast
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.
@ -34,59 +45,320 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr) {
) : ConstraintLayout(context, attrs, defStyleAttr), VoiceMessagePlaybackTracker.Listener {
interface Callback {
fun onVoiceRecordingStarted()
fun onVoiceRecordingEnded(recordTime: Long)
fun checkVoiceRecordingPermission(): Boolean
fun onVoiceRecordingEnded(isCancelled: Boolean)
fun onVoiceRecordingPlaybackModeOn()
fun onVoicePlaybackButtonClicked()
}
private val views: ViewVoiceMessageRecorderBinding
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 {
inflate(context, R.layout.view_voice_message_recorder, this)
views = ViewVoiceMessageRecorderBinding.bind(this)
views.voiceMessageButton.setRecordView(views.voiceMessageRecordView)
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)
}
})
initVoiceRecordingViews()
}
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.voiceMessageLockArrow.isVisible = true
views.voiceMessageLockBackground.animate().setDuration(300).translationY(-dpToPx(148)).start()
views.voiceMessageLockImage.isVisible = true
views.voiceMessageButton.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_voice_mic_recording))
callback?.onVoiceRecordingStarted()
views.voiceMessageLockImage.setImageResource(R.drawable.ic_voice_message_unlocked)
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.voiceMessageLockArrow.isVisible = false
views.voiceMessageLockBackground.animate().translationY(dpToPx(0)).start()
views.voiceMessageLockImage.isVisible = false
views.voiceMessageButton.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_voice_mic))
callback?.onVoiceRecordingEnded(recordTime)
views.voiceMessageLockImage.animate().translationY(dpToPx(0)).start()
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.ReadReceipt
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.MessageVideoContent
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?)
fun getPreviewUrlRetriever(): PreviewUrlRetriever
fun onVoiceControlButtonClicked(eventId: String, messageAudioContent: MessageAudioContent)
}
interface ReactionPillCallback {

View File

@ -25,6 +25,7 @@ import android.text.style.ForegroundColorSpan
import android.view.View
import dagger.Lazy
import im.vector.app.R
import im.vector.app.core.epoxy.ClickListener
import im.vector.app.core.epoxy.VectorEpoxyModel
import im.vector.app.core.files.LocalFilesHelper
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.MessageItemAttributesFactory
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.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.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.VerificationRequestItem
@ -110,7 +114,8 @@ class MessageItemFactory @Inject constructor(
private val avatarSizeProvider: AvatarSizeProvider,
private val pillsPostProcessorFactory: PillsPostProcessor.Factory,
private val spanUtils: SpanUtils,
private val session: Session) {
private val session: Session,
private val voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker) {
// TODO inject this properly?
private var roomId: String = ""
@ -154,7 +159,13 @@ class MessageItemFactory @Inject constructor(
is MessageNoticeContent -> buildNoticeMessageItem(messageContent, informationData, highlight, callback, attributes)
is MessageVideoContent -> buildVideoMessageItem(messageContent, informationData, highlight, callback, 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 MessageOptionsContent -> buildOptionsMessageItem(messageContent, informationData, highlight, callback, attributes)
is MessagePollResponseContent -> noticeItemFactory.create(params)
@ -223,6 +234,46 @@ class MessageItemFactory @Inject constructor(
.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,
@Suppress("UNUSED_PARAMETER")
informationData: MessageInformationData,

View File

@ -23,6 +23,7 @@ import im.vector.app.core.resources.StringProvider
import me.gujun.android.span.span
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent
import org.matrix.android.sdk.api.session.room.model.message.MessageOptionsContent
import org.matrix.android.sdk.api.session.room.model.message.MessageType
import org.matrix.android.sdk.api.session.room.model.message.OPTION_TYPE_BUTTONS
@ -72,7 +73,11 @@ class DisplayableEventFormatter @Inject constructor(
return simpleFormat(senderName, stringProvider.getString(R.string.sent_an_image), appendAuthor)
}
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 -> {
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()
}
}
}