mirror of
https://github.com/nextcloud/talk-android.git
synced 2024-11-23 13:35:33 +03:00
Merge pull request #3202 from nextcloud/seekbar-waveform-for-audio-messages
Waveform SeekBar for Voice Messages
This commit is contained in:
commit
3ab4d99c4a
9 changed files with 475 additions and 70 deletions
|
@ -101,8 +101,8 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) :
|
||||||
setParentMessageDataOnMessageItem(message)
|
setParentMessageDataOnMessageItem(message)
|
||||||
|
|
||||||
updateDownloadState(message)
|
updateDownloadState(message)
|
||||||
binding.seekbar.max = message.voiceMessageDuration - 1
|
binding.seekbar.max = message.voiceMessageDuration * ONE_SEC
|
||||||
viewThemeUtils.platform.themeHorizontalSeekBar(binding.seekbar)
|
viewThemeUtils.talk.themeWaveFormSeekBar(binding.seekbar)
|
||||||
viewThemeUtils.platform.colorCircularProgressBar(binding.progressBar, ColorRole.ON_SURFACE_VARIANT)
|
viewThemeUtils.platform.colorCircularProgressBar(binding.progressBar, ColorRole.ON_SURFACE_VARIANT)
|
||||||
|
|
||||||
if (message.isPlayingVoiceMessage) {
|
if (message.isPlayingVoiceMessage) {
|
||||||
|
@ -115,7 +115,7 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) :
|
||||||
val t = message.voiceMessagePlayedSeconds.toLong()
|
val t = message.voiceMessagePlayedSeconds.toLong()
|
||||||
binding.voiceMessageDuration.text = android.text.format.DateUtils.formatElapsedTime(d - t)
|
binding.voiceMessageDuration.text = android.text.format.DateUtils.formatElapsedTime(d - t)
|
||||||
binding.voiceMessageDuration.visibility = View.VISIBLE
|
binding.voiceMessageDuration.visibility = View.VISIBLE
|
||||||
binding.seekbar.setProgress(message.voiceMessagePlayedSeconds, true)
|
binding.seekbar.progress = message.voiceMessageSeekbarProgress
|
||||||
} else {
|
} else {
|
||||||
binding.playPauseBtn.visibility = View.VISIBLE
|
binding.playPauseBtn.visibility = View.VISIBLE
|
||||||
binding.playPauseBtn.icon = ContextCompat.getDrawable(
|
binding.playPauseBtn.icon = ContextCompat.getDrawable(
|
||||||
|
@ -127,6 +127,11 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) :
|
||||||
if (message.isDownloadingVoiceMessage) {
|
if (message.isDownloadingVoiceMessage) {
|
||||||
showVoiceMessageLoading()
|
showVoiceMessageLoading()
|
||||||
} else {
|
} else {
|
||||||
|
if (message.voiceMessageFloatArray == null || message.voiceMessageFloatArray!!.isEmpty()) {
|
||||||
|
binding.seekbar.setWaveData(FloatArray(0))
|
||||||
|
} else {
|
||||||
|
binding.seekbar.setWaveData(message.voiceMessageFloatArray!!)
|
||||||
|
}
|
||||||
binding.progressBar.visibility = View.GONE
|
binding.progressBar.visibility = View.GONE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -139,7 +144,7 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) :
|
||||||
binding.seekbar.progress = SEEKBAR_START
|
binding.seekbar.progress = SEEKBAR_START
|
||||||
message.resetVoiceMessage = false
|
message.resetVoiceMessage = false
|
||||||
message.voiceMessagePlayedSeconds = 0
|
message.voiceMessagePlayedSeconds = 0
|
||||||
binding.voiceMessageDuration.visibility = View.GONE
|
binding.voiceMessageDuration.visibility = View.INVISIBLE
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.seekbar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
binding.seekbar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
||||||
|
@ -330,5 +335,6 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) :
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "VoiceInMessageView"
|
private const val TAG = "VoiceInMessageView"
|
||||||
private const val SEEKBAR_START: Int = 0
|
private const val SEEKBAR_START: Int = 0
|
||||||
|
private const val ONE_SEC: Int = 1000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -98,8 +98,8 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) :
|
||||||
setParentMessageDataOnMessageItem(message)
|
setParentMessageDataOnMessageItem(message)
|
||||||
|
|
||||||
updateDownloadState(message)
|
updateDownloadState(message)
|
||||||
binding.seekbar.max = message.voiceMessageDuration - 1
|
binding.seekbar.max = message.voiceMessageDuration * ONE_SEC
|
||||||
viewThemeUtils.platform.themeHorizontalSeekBar(binding.seekbar)
|
viewThemeUtils.talk.themeWaveFormSeekBar(binding.seekbar)
|
||||||
viewThemeUtils.platform.colorCircularProgressBar(binding.progressBar, ColorRole.ON_SURFACE_VARIANT)
|
viewThemeUtils.platform.colorCircularProgressBar(binding.progressBar, ColorRole.ON_SURFACE_VARIANT)
|
||||||
|
|
||||||
handleIsPlayingVoiceMessageState(message)
|
handleIsPlayingVoiceMessageState(message)
|
||||||
|
@ -176,7 +176,7 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) :
|
||||||
)
|
)
|
||||||
binding.seekbar.progress = SEEKBAR_START
|
binding.seekbar.progress = SEEKBAR_START
|
||||||
message.voiceMessagePlayedSeconds = 0
|
message.voiceMessagePlayedSeconds = 0
|
||||||
binding.voiceMessageDuration.visibility = View.GONE
|
binding.voiceMessageDuration.visibility = View.INVISIBLE
|
||||||
message.resetVoiceMessage = false
|
message.resetVoiceMessage = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -185,6 +185,11 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) :
|
||||||
if (message.isDownloadingVoiceMessage) {
|
if (message.isDownloadingVoiceMessage) {
|
||||||
showVoiceMessageLoading()
|
showVoiceMessageLoading()
|
||||||
} else {
|
} else {
|
||||||
|
if (message.voiceMessageFloatArray == null || message.voiceMessageFloatArray!!.isEmpty()) {
|
||||||
|
binding.seekbar.setWaveData(FloatArray(0))
|
||||||
|
} else {
|
||||||
|
binding.seekbar.setWaveData(message.voiceMessageFloatArray!!)
|
||||||
|
}
|
||||||
binding.progressBar.visibility = View.GONE
|
binding.progressBar.visibility = View.GONE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -201,7 +206,7 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) :
|
||||||
val t = message.voiceMessagePlayedSeconds.toLong()
|
val t = message.voiceMessagePlayedSeconds.toLong()
|
||||||
binding.voiceMessageDuration.text = android.text.format.DateUtils.formatElapsedTime(d - t)
|
binding.voiceMessageDuration.text = android.text.format.DateUtils.formatElapsedTime(d - t)
|
||||||
binding.voiceMessageDuration.visibility = View.VISIBLE
|
binding.voiceMessageDuration.visibility = View.VISIBLE
|
||||||
binding.seekbar.setProgress(message.voiceMessagePlayedSeconds, true)
|
binding.seekbar.progress = message.voiceMessageSeekbarProgress
|
||||||
} else {
|
} else {
|
||||||
binding.playPauseBtn.visibility = View.VISIBLE
|
binding.playPauseBtn.visibility = View.VISIBLE
|
||||||
binding.playPauseBtn.icon = ContextCompat.getDrawable(
|
binding.playPauseBtn.icon = ContextCompat.getDrawable(
|
||||||
|
@ -313,5 +318,6 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) :
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "VoiceOutMessageView"
|
private const val TAG = "VoiceOutMessageView"
|
||||||
private const val SEEKBAR_START: Int = 0
|
private const val SEEKBAR_START: Int = 0
|
||||||
|
private const val ONE_SEC: Int = 1000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -188,6 +188,7 @@ import com.nextcloud.talk.ui.dialog.ShowReactionsDialog
|
||||||
import com.nextcloud.talk.ui.recyclerview.MessageSwipeActions
|
import com.nextcloud.talk.ui.recyclerview.MessageSwipeActions
|
||||||
import com.nextcloud.talk.ui.recyclerview.MessageSwipeCallback
|
import com.nextcloud.talk.ui.recyclerview.MessageSwipeCallback
|
||||||
import com.nextcloud.talk.utils.ApiUtils
|
import com.nextcloud.talk.utils.ApiUtils
|
||||||
|
import com.nextcloud.talk.utils.AudioUtils
|
||||||
import com.nextcloud.talk.utils.ContactUtils
|
import com.nextcloud.talk.utils.ContactUtils
|
||||||
import com.nextcloud.talk.utils.ConversationUtils
|
import com.nextcloud.talk.utils.ConversationUtils
|
||||||
import com.nextcloud.talk.utils.DateConstants
|
import com.nextcloud.talk.utils.DateConstants
|
||||||
|
@ -232,6 +233,10 @@ import io.reactivex.Observer
|
||||||
import io.reactivex.android.schedulers.AndroidSchedulers
|
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||||
import io.reactivex.disposables.Disposable
|
import io.reactivex.disposables.Disposable
|
||||||
import io.reactivex.schedulers.Schedulers
|
import io.reactivex.schedulers.Schedulers
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import org.greenrobot.eventbus.Subscribe
|
import org.greenrobot.eventbus.Subscribe
|
||||||
import org.greenrobot.eventbus.ThreadMode
|
import org.greenrobot.eventbus.ThreadMode
|
||||||
import retrofit2.HttpException
|
import retrofit2.HttpException
|
||||||
|
@ -343,6 +348,11 @@ class ChatActivity :
|
||||||
AudioFormat.CHANNEL_IN_MONO,
|
AudioFormat.CHANNEL_IN_MONO,
|
||||||
AudioFormat.ENCODING_PCM_16BIT
|
AudioFormat.ENCODING_PCM_16BIT
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// messy workaround for a mediaPlayer bug, don't delete
|
||||||
|
private var lastRecordMediaPosition: Int = 0
|
||||||
|
private var lastRecordedSeeked: Boolean = false
|
||||||
|
|
||||||
private lateinit var participantPermissions: ParticipantPermissions
|
private lateinit var participantPermissions: ParticipantPermissions
|
||||||
|
|
||||||
private var videoURI: Uri? = null
|
private var videoURI: Uri? = null
|
||||||
|
@ -861,21 +871,45 @@ class ChatActivity :
|
||||||
adapter?.setOnMessageViewLongClickListener { view, message -> onMessageViewLongClick(view, message) }
|
adapter?.setOnMessageViewLongClickListener { view, message -> onMessageViewLongClick(view, message) }
|
||||||
adapter?.registerViewClickListener(
|
adapter?.registerViewClickListener(
|
||||||
R.id.playPauseBtn
|
R.id.playPauseBtn
|
||||||
) { view, message ->
|
) { _, message ->
|
||||||
val filename = message.selectedIndividualHashMap!!["name"]
|
val filename = message.selectedIndividualHashMap!!["name"]
|
||||||
val file = File(context.cacheDir, filename!!)
|
val file = File(context.cacheDir, filename!!)
|
||||||
if (file.exists()) {
|
if (file.exists()) {
|
||||||
if (message.isPlayingVoiceMessage) {
|
if (message.isPlayingVoiceMessage) {
|
||||||
pausePlayback(message)
|
pausePlayback(message)
|
||||||
} else {
|
} else {
|
||||||
startPlayback(message)
|
setUpWaveform(message)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
Log.d(TAG, "Downloaded to cache")
|
||||||
downloadFileToCache(message)
|
downloadFileToCache(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun setUpWaveform(message: ChatMessage) {
|
||||||
|
val filename = message.selectedIndividualHashMap!!["name"]
|
||||||
|
val file = File(context.cacheDir, filename!!)
|
||||||
|
if (file.exists() && message.voiceMessageFloatArray == null) {
|
||||||
|
message.isDownloadingVoiceMessage = true
|
||||||
|
adapter?.update(message)
|
||||||
|
CoroutineScope(Dispatchers.Default).launch {
|
||||||
|
val bars = if (message.actorDisplayName == conversationUser?.displayName) {
|
||||||
|
NUM_BARS_OUTCOMING
|
||||||
|
} else {
|
||||||
|
NUM_BARS_INCOMING
|
||||||
|
}
|
||||||
|
val r = AudioUtils.audioFileToFloatArray(file, bars)
|
||||||
|
message.voiceMessageFloatArray = r
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
startPlayback(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
startPlayback(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun initMessageHolders(): MessageHolders {
|
private fun initMessageHolders(): MessageHolders {
|
||||||
val messageHolders = MessageHolders()
|
val messageHolders = MessageHolders()
|
||||||
val profileBottomSheet = ProfileBottomSheet(ncApi, conversationUser!!)
|
val profileBottomSheet = ProfileBottomSheet(ncApi, conversationUser!!)
|
||||||
|
@ -1215,7 +1249,6 @@ class ChatActivity :
|
||||||
setDataSource(currentVoiceRecordFile)
|
setDataSource(currentVoiceRecordFile)
|
||||||
prepare()
|
prepare()
|
||||||
setOnPreparedListener {
|
setOnPreparedListener {
|
||||||
Log.d(TAG, "Julius the duration is ${it.duration}")
|
|
||||||
binding.messageInputView.seekBar.progress = 0
|
binding.messageInputView.seekBar.progress = 0
|
||||||
binding.messageInputView.seekBar.max = it.duration
|
binding.messageInputView.seekBar.max = it.duration
|
||||||
voicePreviewObjectAnimator = ObjectAnimator.ofInt(
|
voicePreviewObjectAnimator = ObjectAnimator.ofInt(
|
||||||
|
@ -1742,6 +1775,7 @@ class ChatActivity :
|
||||||
mediaPlayer?.let {
|
mediaPlayer?.let {
|
||||||
if (!it.isPlaying) {
|
if (!it.isPlaying) {
|
||||||
it.start()
|
it.start()
|
||||||
|
Log.d(TAG, "MediaPlayer has Started")
|
||||||
}
|
}
|
||||||
|
|
||||||
mediaPlayerHandler = Handler()
|
mediaPlayerHandler = Handler()
|
||||||
|
@ -1751,17 +1785,20 @@ class ChatActivity :
|
||||||
if (message.isPlayingVoiceMessage) {
|
if (message.isPlayingVoiceMessage) {
|
||||||
val pos = mediaPlayer!!.currentPosition / VOICE_MESSAGE_SEEKBAR_BASE
|
val pos = mediaPlayer!!.currentPosition / VOICE_MESSAGE_SEEKBAR_BASE
|
||||||
if (pos < (mediaPlayer!!.duration / VOICE_MESSAGE_SEEKBAR_BASE)) {
|
if (pos < (mediaPlayer!!.duration / VOICE_MESSAGE_SEEKBAR_BASE)) {
|
||||||
|
lastRecordMediaPosition = mediaPlayer!!.currentPosition
|
||||||
message.voiceMessagePlayedSeconds = pos
|
message.voiceMessagePlayedSeconds = pos
|
||||||
|
message.voiceMessageSeekbarProgress = mediaPlayer!!.currentPosition
|
||||||
adapter?.update(message)
|
adapter?.update(message)
|
||||||
} else {
|
} else {
|
||||||
message.resetVoiceMessage = true
|
message.resetVoiceMessage = true
|
||||||
message.voiceMessagePlayedSeconds = 0
|
message.voiceMessagePlayedSeconds = 0
|
||||||
|
message.voiceMessageSeekbarProgress = 0
|
||||||
adapter?.update(message)
|
adapter?.update(message)
|
||||||
stopMediaPlayer(message)
|
stopMediaPlayer(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mediaPlayerHandler.postDelayed(this, SECOND)
|
mediaPlayerHandler.postDelayed(this, 15)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -1774,6 +1811,7 @@ class ChatActivity :
|
||||||
private fun pausePlayback(message: ChatMessage) {
|
private fun pausePlayback(message: ChatMessage) {
|
||||||
if (mediaPlayer!!.isPlaying) {
|
if (mediaPlayer!!.isPlaying) {
|
||||||
mediaPlayer!!.pause()
|
mediaPlayer!!.pause()
|
||||||
|
Log.d(TAG, "MediaPlayer is paused")
|
||||||
}
|
}
|
||||||
|
|
||||||
message.isPlayingVoiceMessage = false
|
message.isPlayingVoiceMessage = false
|
||||||
|
@ -1794,13 +1832,22 @@ class ChatActivity :
|
||||||
mediaPlayer = MediaPlayer().apply {
|
mediaPlayer = MediaPlayer().apply {
|
||||||
setDataSource(absolutePath)
|
setDataSource(absolutePath)
|
||||||
prepare()
|
prepare()
|
||||||
}
|
setOnPreparedListener {
|
||||||
|
currentlyPlayedVoiceMessage = message
|
||||||
currentlyPlayedVoiceMessage = message
|
message.voiceMessageDuration = mediaPlayer!!.duration / VOICE_MESSAGE_SEEKBAR_BASE
|
||||||
message.voiceMessageDuration = mediaPlayer!!.duration / VOICE_MESSAGE_SEEKBAR_BASE
|
lastRecordedSeeked = false
|
||||||
|
}
|
||||||
mediaPlayer!!.setOnCompletionListener {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
stopMediaPlayer(message)
|
setOnMediaTimeDiscontinuityListener { mp, _ ->
|
||||||
|
if (lastRecordMediaPosition > ONE_SECOND_IN_MILLIS && !lastRecordedSeeked) {
|
||||||
|
mp.seekTo(lastRecordMediaPosition)
|
||||||
|
lastRecordedSeeked = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setOnCompletionListener {
|
||||||
|
stopMediaPlayer(message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "failed to initialize mediaPlayer", e)
|
Log.e(TAG, "failed to initialize mediaPlayer", e)
|
||||||
|
@ -1836,7 +1883,7 @@ class ChatActivity :
|
||||||
override fun updateMediaPlayerProgressBySlider(messageWithSlidedProgress: ChatMessage, progress: Int) {
|
override fun updateMediaPlayerProgressBySlider(messageWithSlidedProgress: ChatMessage, progress: Int) {
|
||||||
if (mediaPlayer != null) {
|
if (mediaPlayer != null) {
|
||||||
if (messageWithSlidedProgress == currentlyPlayedVoiceMessage) {
|
if (messageWithSlidedProgress == currentlyPlayedVoiceMessage) {
|
||||||
mediaPlayer!!.seekTo(progress * VOICE_MESSAGE_SEEKBAR_BASE)
|
mediaPlayer!!.seekTo(progress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1894,7 +1941,8 @@ class ChatActivity :
|
||||||
WorkManager.getInstance(context).getWorkInfoByIdLiveData(downloadWorker.id)
|
WorkManager.getInstance(context).getWorkInfoByIdLiveData(downloadWorker.id)
|
||||||
.observeForever { workInfo: WorkInfo ->
|
.observeForever { workInfo: WorkInfo ->
|
||||||
if (workInfo.state == WorkInfo.State.SUCCEEDED) {
|
if (workInfo.state == WorkInfo.State.SUCCEEDED) {
|
||||||
startPlayback(message)
|
setUpWaveform(message)
|
||||||
|
// startPlayback(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4227,5 +4275,7 @@ class ChatActivity :
|
||||||
private const val TYPING_INTERVAL_TO_SEND_NEXT_TYPING_MESSAGE = 1000L
|
private const val TYPING_INTERVAL_TO_SEND_NEXT_TYPING_MESSAGE = 1000L
|
||||||
private const val TYPING_STARTED_SIGNALING_MESSAGE_TYPE = "startedTyping"
|
private const val TYPING_STARTED_SIGNALING_MESSAGE_TYPE = "startedTyping"
|
||||||
private const val TYPING_STOPPED_SIGNALING_MESSAGE_TYPE = "stoppedTyping"
|
private const val TYPING_STOPPED_SIGNALING_MESSAGE_TYPE = "stoppedTyping"
|
||||||
|
private const val NUM_BARS_OUTCOMING: Int = 38
|
||||||
|
private const val NUM_BARS_INCOMING: Int = 50
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,7 +42,6 @@ import com.stfalcon.chatkit.commons.models.IUser
|
||||||
import com.stfalcon.chatkit.commons.models.MessageContentType
|
import com.stfalcon.chatkit.commons.models.MessageContentType
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.util.Arrays
|
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
|
@ -132,7 +131,11 @@ data class ChatMessage(
|
||||||
|
|
||||||
var voiceMessagePlayedSeconds: Int = 0,
|
var voiceMessagePlayedSeconds: Int = 0,
|
||||||
|
|
||||||
var voiceMessageDownloadProgress: Int = 0
|
var voiceMessageDownloadProgress: Int = 0,
|
||||||
|
|
||||||
|
var voiceMessageSeekbarProgress: Int = 0,
|
||||||
|
|
||||||
|
var voiceMessageFloatArray: FloatArray? = null
|
||||||
|
|
||||||
) : Parcelable, MessageContentType, MessageContentType.Image {
|
) : Parcelable, MessageContentType, MessageContentType.Image {
|
||||||
|
|
||||||
|
@ -140,7 +143,7 @@ data class ChatMessage(
|
||||||
|
|
||||||
// messageTypesToIgnore is weird. must be deleted by refactoring!!!
|
// messageTypesToIgnore is weird. must be deleted by refactoring!!!
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
var messageTypesToIgnore = Arrays.asList(
|
var messageTypesToIgnore = listOf(
|
||||||
MessageType.REGULAR_TEXT_MESSAGE,
|
MessageType.REGULAR_TEXT_MESSAGE,
|
||||||
MessageType.SYSTEM_MESSAGE,
|
MessageType.SYSTEM_MESSAGE,
|
||||||
MessageType.SINGLE_LINK_VIDEO_MESSAGE,
|
MessageType.SINGLE_LINK_VIDEO_MESSAGE,
|
||||||
|
@ -417,6 +420,17 @@ data class ChatMessage(
|
||||||
return map != null && MessageDigest.isEqual(map[key]!!.toByteArray(), searchTerm.toByteArray())
|
return map != null && MessageDigest.isEqual(map[key]!!.toByteArray(), searchTerm.toByteArray())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// needed a equals and hashcode function to fix detekt errors
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
val isVoiceMessage: Boolean
|
val isVoiceMessage: Boolean
|
||||||
get() = "voice-message" == messageType
|
get() = "voice-message" == messageType
|
||||||
val isCommandMessage: Boolean
|
val isCommandMessage: Boolean
|
||||||
|
|
115
app/src/main/java/com/nextcloud/talk/ui/WaveformSeekBar.kt
Normal file
115
app/src/main/java/com/nextcloud/talk/ui/WaveformSeekBar.kt
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
/*
|
||||||
|
* Nextcloud Talk application
|
||||||
|
*
|
||||||
|
* @author Julius Linus
|
||||||
|
* Copyright (C) 2023 Julius Linus <julius.linus@nextcloud.com>
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.nextcloud.talk.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.res.Resources
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import androidx.annotation.ColorInt
|
||||||
|
import androidx.appcompat.widget.AppCompatSeekBar
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
class WaveformSeekBar : AppCompatSeekBar {
|
||||||
|
|
||||||
|
@ColorInt
|
||||||
|
private var primary: Int = Color.parseColor("#679ff5")
|
||||||
|
|
||||||
|
@ColorInt
|
||||||
|
private var secondary: Int = Color.parseColor("#a6c6f7")
|
||||||
|
private var waveData: FloatArray = floatArrayOf()
|
||||||
|
private val paint: Paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
|
||||||
|
constructor(context: Context) : super(context) {
|
||||||
|
init()
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
|
||||||
|
init()
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
|
||||||
|
init()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setColors(@ColorInt p: Int, @ColorInt s: Int) {
|
||||||
|
primary = p
|
||||||
|
secondary = s
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setWaveData(data: FloatArray) {
|
||||||
|
waveData = data
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun init() {
|
||||||
|
paint.apply {
|
||||||
|
strokeCap = Paint.Cap.ROUND
|
||||||
|
strokeWidth = DEFAULT_BAR_WIDTH.dp.toFloat()
|
||||||
|
color = Color.RED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDraw(canvas: Canvas?) {
|
||||||
|
if (waveData.isEmpty() || waveData[0].toString() == "NaN") {
|
||||||
|
super.onDraw(canvas)
|
||||||
|
} else {
|
||||||
|
if (progressDrawable != null) {
|
||||||
|
super.setProgressDrawable(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
drawWaveformSeekbar(canvas)
|
||||||
|
super.onDraw(canvas)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drawWaveformSeekbar(canvas: Canvas?) {
|
||||||
|
val usableHeight = height - paddingTop - paddingBottom
|
||||||
|
val usableWidth = width - paddingLeft - paddingRight
|
||||||
|
val midpoint = usableHeight / 2f
|
||||||
|
val maxHeight: Float = usableHeight / MAX_HEIGHT_DIVISOR
|
||||||
|
val barGap: Float = (usableWidth - waveData.size * DEFAULT_BAR_WIDTH) / (waveData.size - 1).toFloat()
|
||||||
|
|
||||||
|
canvas?.apply {
|
||||||
|
save()
|
||||||
|
translate(paddingLeft.toFloat(), paddingTop.toFloat())
|
||||||
|
for (i in waveData.indices) {
|
||||||
|
val x: Float = i * (DEFAULT_BAR_WIDTH + barGap) + DEFAULT_BAR_WIDTH / 2f
|
||||||
|
val y: Float = waveData[i] * maxHeight
|
||||||
|
val progress = (x / usableWidth)
|
||||||
|
paint.color = if (progress * max < getProgress()) primary else secondary
|
||||||
|
canvas.drawLine(x, midpoint - y, x, midpoint + y, paint)
|
||||||
|
}
|
||||||
|
|
||||||
|
restore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val DEFAULT_BAR_WIDTH: Int = 2
|
||||||
|
private const val MAX_HEIGHT_DIVISOR: Float = 4.0f
|
||||||
|
private val Int.dp: Int
|
||||||
|
get() = (this * Resources.getSystem().displayMetrics.density).roundToInt()
|
||||||
|
}
|
||||||
|
}
|
|
@ -24,6 +24,8 @@ package com.nextcloud.talk.ui.theme
|
||||||
import android.annotation.TargetApi
|
import android.annotation.TargetApi
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.ColorStateList
|
import android.content.res.ColorStateList
|
||||||
|
import android.graphics.PorterDuff
|
||||||
|
import android.graphics.PorterDuffColorFilter
|
||||||
import android.graphics.drawable.Drawable
|
import android.graphics.drawable.Drawable
|
||||||
import android.graphics.drawable.LayerDrawable
|
import android.graphics.drawable.LayerDrawable
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
@ -47,6 +49,7 @@ import com.nextcloud.android.common.ui.theme.ViewThemeUtilsBase
|
||||||
import com.nextcloud.android.common.ui.theme.utils.AndroidXViewThemeUtils
|
import com.nextcloud.android.common.ui.theme.utils.AndroidXViewThemeUtils
|
||||||
import com.nextcloud.talk.R
|
import com.nextcloud.talk.R
|
||||||
import com.nextcloud.talk.ui.MicInputCloud
|
import com.nextcloud.talk.ui.MicInputCloud
|
||||||
|
import com.nextcloud.talk.ui.WaveformSeekBar
|
||||||
import com.nextcloud.talk.utils.DisplayUtils
|
import com.nextcloud.talk.utils.DisplayUtils
|
||||||
import com.nextcloud.talk.utils.DrawableUtils
|
import com.nextcloud.talk.utils.DrawableUtils
|
||||||
import com.vanniktech.emoji.EmojiTextView
|
import com.vanniktech.emoji.EmojiTextView
|
||||||
|
@ -250,6 +253,16 @@ class TalkSpecificViewThemeUtils @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun themeWaveFormSeekBar(waveformSeekBar: WaveformSeekBar) {
|
||||||
|
withScheme(waveformSeekBar) { scheme ->
|
||||||
|
waveformSeekBar.thumb.colorFilter =
|
||||||
|
PorterDuffColorFilter(scheme.inversePrimary, PorterDuff.Mode.SRC_IN)
|
||||||
|
waveformSeekBar.setColors(scheme.inversePrimary, scheme.onPrimaryContainer)
|
||||||
|
waveformSeekBar.progressDrawable?.colorFilter =
|
||||||
|
PorterDuffColorFilter(scheme.primary, PorterDuff.Mode.SRC_IN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val THEMEABLE_PLACEHOLDER_IDS = listOf(
|
private val THEMEABLE_PLACEHOLDER_IDS = listOf(
|
||||||
R.drawable.ic_mimetype_package_x_generic,
|
R.drawable.ic_mimetype_package_x_generic,
|
||||||
|
|
167
app/src/main/java/com/nextcloud/talk/utils/AudioUtils.kt
Normal file
167
app/src/main/java/com/nextcloud/talk/utils/AudioUtils.kt
Normal file
|
@ -0,0 +1,167 @@
|
||||||
|
/*
|
||||||
|
* Nextcloud Talk application
|
||||||
|
*
|
||||||
|
* @author Julius Linus
|
||||||
|
* Copyright (C) 2023 Julius Linus <julius.linus@nextcloud.com>
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.nextcloud.talk.utils
|
||||||
|
|
||||||
|
import android.media.AudioFormat
|
||||||
|
import android.media.MediaCodec
|
||||||
|
import android.media.MediaCodec.CodecException
|
||||||
|
import android.media.MediaCodecList
|
||||||
|
import android.media.MediaExtractor
|
||||||
|
import android.media.MediaFormat
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.util.Log
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AudioUtils are for processing raw audio using android's low level APIs, for more information read here
|
||||||
|
* [MediaCodec documentation](https://developer.android.com/reference/android/media/MediaCodec)
|
||||||
|
*/
|
||||||
|
object AudioUtils {
|
||||||
|
private val TAG = AudioUtils::class.java.simpleName
|
||||||
|
private const val VALUE_10 = 10
|
||||||
|
private const val TIME_LIMIT = 5000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suspension function, returns a FloatArray containing the values of an audio file squeezed between [0,1)
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
suspend fun audioFileToFloatArray(file: File, size: Int): FloatArray {
|
||||||
|
return suspendCoroutine {
|
||||||
|
val startTime = SystemClock.elapsedRealtime()
|
||||||
|
var result = mutableListOf<Float>()
|
||||||
|
val path = file.path
|
||||||
|
val mediaExtractor = MediaExtractor()
|
||||||
|
mediaExtractor.setDataSource(path)
|
||||||
|
|
||||||
|
val mediaFormat = mediaExtractor.getTrackFormat(0)
|
||||||
|
mediaFormat.setString(MediaFormat.KEY_FRAME_RATE, null)
|
||||||
|
mediaFormat.setInteger(MediaFormat.KEY_FRAME_RATE, 0)
|
||||||
|
|
||||||
|
mediaExtractor.release()
|
||||||
|
|
||||||
|
val mediaCodecList = MediaCodecList(MediaCodecList.ALL_CODECS)
|
||||||
|
val codecName = mediaCodecList.findDecoderForFormat(mediaFormat)
|
||||||
|
val mediaCodec = MediaCodec.createByCodecName(codecName)
|
||||||
|
mediaCodec.setCallback(object : MediaCodec.Callback() {
|
||||||
|
private var extractor: MediaExtractor? = null
|
||||||
|
val tempList = mutableListOf<Float>()
|
||||||
|
override fun onInputBufferAvailable(codec: MediaCodec, index: Int) {
|
||||||
|
if (extractor == null) {
|
||||||
|
extractor = MediaExtractor()
|
||||||
|
try {
|
||||||
|
extractor!!.setDataSource(path)
|
||||||
|
extractor!!.selectTrack(0)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val byteBuffer = codec.getInputBuffer(index)
|
||||||
|
if (byteBuffer != null) {
|
||||||
|
val sampleSize = extractor!!.readSampleData(byteBuffer, 0)
|
||||||
|
if (sampleSize > 0) {
|
||||||
|
val isOver = !extractor!!.advance()
|
||||||
|
codec.queueInputBuffer(
|
||||||
|
index,
|
||||||
|
0,
|
||||||
|
sampleSize,
|
||||||
|
extractor!!.sampleTime,
|
||||||
|
if (isOver) MediaCodec.BUFFER_FLAG_END_OF_STREAM else 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOutputBufferAvailable(codec: MediaCodec, index: Int, info: MediaCodec.BufferInfo) {
|
||||||
|
val outputBuffer = codec.getOutputBuffer(index)
|
||||||
|
val bufferFormat = codec.getOutputFormat(index)
|
||||||
|
val samples = outputBuffer!!.order(ByteOrder.nativeOrder()).asShortBuffer()
|
||||||
|
val numChannels = bufferFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
|
||||||
|
if (index < 0 || index >= numChannels) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val sampleLength = (samples.remaining() / numChannels)
|
||||||
|
// Squeezes the value of each sample between [0,1) using y = (x-1)/x
|
||||||
|
for (i in 0 until sampleLength) {
|
||||||
|
val x = abs(samples[i * numChannels + index].toInt()) / VALUE_10
|
||||||
|
val y = (if (x > 0) ((x - 1) / x.toFloat()) else x.toFloat())
|
||||||
|
tempList.add(y)
|
||||||
|
}
|
||||||
|
codec.releaseOutputBuffer(index, false)
|
||||||
|
val currTime = SystemClock.elapsedRealtime() - startTime
|
||||||
|
if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM > 0 || currTime > TIME_LIMIT) {
|
||||||
|
codec.stop()
|
||||||
|
codec.release()
|
||||||
|
extractor!!.release()
|
||||||
|
extractor = null
|
||||||
|
if (currTime < TIME_LIMIT) {
|
||||||
|
result = tempList
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "time limit exceeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(codec: MediaCodec, e: CodecException) {
|
||||||
|
Log.e(TAG, "Error in MediaCodec Callback: \n$e")
|
||||||
|
codec.stop()
|
||||||
|
codec.release()
|
||||||
|
extractor!!.release()
|
||||||
|
extractor = null
|
||||||
|
result = tempList
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) {
|
||||||
|
// unused atm
|
||||||
|
}
|
||||||
|
})
|
||||||
|
mediaFormat.setInteger(MediaFormat.KEY_PCM_ENCODING, AudioFormat.ENCODING_PCM_16BIT)
|
||||||
|
mediaCodec.configure(mediaFormat, null, null, 0)
|
||||||
|
mediaCodec.start()
|
||||||
|
while (result.size <= 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
it.resume(shrinkFloatArray(result.toFloatArray(), size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shrinkFloatArray(data: FloatArray, size: Int): FloatArray {
|
||||||
|
val result = FloatArray(size)
|
||||||
|
val scale = data.size / size
|
||||||
|
var begin = 0
|
||||||
|
var end = scale
|
||||||
|
for (i in 0 until size) {
|
||||||
|
val arr = data.copyOfRange(begin, end)
|
||||||
|
var sum = 0f
|
||||||
|
for (j in arr.indices) {
|
||||||
|
sum += arr[j]
|
||||||
|
}
|
||||||
|
result[i] = (sum / arr.size)
|
||||||
|
begin += scale
|
||||||
|
end += scale
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
|
@ -91,32 +91,55 @@
|
||||||
app:iconSize="40dp"
|
app:iconSize="40dp"
|
||||||
app:iconTint="@color/nc_incoming_text_default" />
|
app:iconTint="@color/nc_incoming_text_default" />
|
||||||
|
|
||||||
<SeekBar
|
|
||||||
|
<com.nextcloud.talk.ui.WaveformSeekBar
|
||||||
android:id="@+id/seekbar"
|
android:id="@+id/seekbar"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="70dp"
|
||||||
android:layout_weight="1"
|
android:thumb="@drawable/voice_message_outgoing_seek_bar_slider"
|
||||||
tools:progress="50" />
|
tools:progress="50"
|
||||||
|
android:layout_weight="1" />
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/voiceMessageDuration"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:visibility="gone"
|
|
||||||
tools:text="00:00" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<TextView
|
<LinearLayout
|
||||||
android:id="@id/messageTime"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_below="@id/messageText"
|
android:layout_below="@id/messageText"
|
||||||
android:layout_marginStart="8dp"
|
android:layout_width="match_parent"
|
||||||
android:alpha="0.6"
|
android:layout_height="wrap_content"
|
||||||
android:textColor="@color/no_emphasis_text"
|
android:orientation="horizontal">
|
||||||
app:layout_alignSelf="center"
|
|
||||||
tools:text="12:38" />
|
<TextView
|
||||||
|
android:id="@+id/voiceMessageDuration"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="@dimen/standard_margin"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:visibility="invisible"
|
||||||
|
tools:text="02:30"
|
||||||
|
tools:visibility="visible" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@id/messageTime"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:alpha="0.6"
|
||||||
|
android:textColor="@color/no_emphasis_text"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
tools:text="10:35" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/checkMark"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="@dimen/message_bubble_checkmark_height"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:contentDescription="@null"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
app:tint="@color/high_emphasis_text" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
|
||||||
<include
|
<include
|
||||||
android:id="@+id/reactions"
|
android:id="@+id/reactions"
|
||||||
|
|
|
@ -76,43 +76,54 @@
|
||||||
app:iconTint="@color/high_emphasis_text"
|
app:iconTint="@color/high_emphasis_text"
|
||||||
app:rippleColor="#1FFFFFFF" />
|
app:rippleColor="#1FFFFFFF" />
|
||||||
|
|
||||||
<SeekBar
|
|
||||||
|
<com.nextcloud.talk.ui.WaveformSeekBar
|
||||||
android:id="@+id/seekbar"
|
android:id="@+id/seekbar"
|
||||||
style="@style/Nextcloud.Material.Outgoing.SeekBar"
|
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="40dp"
|
android:layout_height="70dp"
|
||||||
android:thumb="@drawable/voice_message_outgoing_seek_bar_slider"
|
android:thumb="@drawable/voice_message_outgoing_seek_bar_slider"
|
||||||
tools:progress="50"
|
tools:progress="50"
|
||||||
android:layout_weight="1" />
|
android:layout_weight="1" />
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/voiceMessageDuration"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:visibility="gone" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<TextView
|
<LinearLayout
|
||||||
android:id="@id/messageTime"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_below="@id/messageText"
|
android:layout_below="@id/messageText"
|
||||||
android:layout_marginStart="8dp"
|
android:layout_width="match_parent"
|
||||||
android:alpha="0.6"
|
android:layout_height="wrap_content"
|
||||||
android:textColor="@color/no_emphasis_text"
|
android:orientation="horizontal">
|
||||||
app:layout_alignSelf="center"
|
|
||||||
tools:text="10:35" />
|
<TextView
|
||||||
|
android:id="@+id/voiceMessageDuration"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="@dimen/standard_margin"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:visibility="invisible"
|
||||||
|
tools:text="02:30"
|
||||||
|
tools:visibility="visible" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@id/messageTime"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:alpha="0.6"
|
||||||
|
android:textColor="@color/no_emphasis_text"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
tools:text="10:35" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/checkMark"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="@dimen/message_bubble_checkmark_height"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:contentDescription="@null"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
app:tint="@color/high_emphasis_text" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<ImageView
|
|
||||||
android:id="@+id/checkMark"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="@dimen/message_bubble_checkmark_height"
|
|
||||||
android:layout_below="@id/messageTime"
|
|
||||||
android:layout_marginStart="8dp"
|
|
||||||
android:contentDescription="@null"
|
|
||||||
app:layout_alignSelf="center"
|
|
||||||
app:tint="@color/high_emphasis_text" />
|
|
||||||
|
|
||||||
<include
|
<include
|
||||||
android:id="@+id/reactions"
|
android:id="@+id/reactions"
|
||||||
|
|
Loading…
Reference in a new issue