Added voice recording messages in chat

This commit is contained in:
Sylvain Berfini 2021-06-09 14:09:40 +02:00
parent f7710e2ae2
commit bac8d8e4e8
47 changed files with 1261 additions and 75 deletions

View file

@ -25,16 +25,21 @@ import android.text.SpannableString
import android.text.Spanned
import android.text.style.UnderlineSpan
import androidx.lifecycle.MutableLiveData
import java.text.SimpleDateFormat
import java.util.*
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.ticker
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R
import org.linphone.core.ChatMessage
import org.linphone.core.ChatMessageListenerStub
import org.linphone.core.Content
import org.linphone.core.*
import org.linphone.core.tools.Log
import org.linphone.utils.AppUtils
import org.linphone.utils.FileUtils
import org.linphone.utils.ImageUtils
import java.lang.Exception
class ChatMessageContentData(
private val chatMessage: ChatMessage,
@ -42,6 +47,8 @@ class ChatMessageContentData(
) {
var listener: OnContentClickedListener? = null
val isOutgoing = chatMessage.isOutgoing
val isImage = MutableLiveData<Boolean>()
val isVideo = MutableLiveData<Boolean>()
@ -49,6 +56,7 @@ class ChatMessageContentData(
val videoPreview = MutableLiveData<Bitmap>()
val isPdf = MutableLiveData<Boolean>()
val isGenericFile = MutableLiveData<Boolean>()
val isVoiceRecording = MutableLiveData<Boolean>()
val fileName = MutableLiveData<String>()
val filePath = MutableLiveData<String>()
@ -60,6 +68,11 @@ class ChatMessageContentData(
val downloadProgressString = MutableLiveData<String>()
val downloadLabel = MutableLiveData<Spannable>()
val voiceRecordDuration = MutableLiveData<Int>()
val formattedDuration = MutableLiveData<String>()
val voiceRecordPlayingPosition = MutableLiveData<Int>()
val isVoiceRecordPlaying = MutableLiveData<Boolean>()
val isAlone: Boolean
get() {
var count = 0
@ -74,6 +87,12 @@ class ChatMessageContentData(
var isFileEncrypted: Boolean = false
private lateinit var voiceRecordingPlayer: Player
private val playerListener = PlayerListener {
Log.i("[Voice Recording] End of file reached")
stopVoiceRecording()
}
private fun getContent(): Content {
return chatMessage.contents[contentIndex]
}
@ -111,9 +130,13 @@ class ChatMessageContentData(
}
}
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
init {
isVoiceRecordPlaying.value = false
voiceRecordDuration.value = 0
voiceRecordPlayingPosition.value = 0
updateContent()
chatMessage.addListener(chatMessageListener)
}
@ -129,6 +152,12 @@ class ChatMessageContentData(
}
chatMessage.removeListener(chatMessageListener)
if (this::voiceRecordingPlayer.isInitialized) {
Log.i("[Voice Recording] Destroying voice record")
stopVoiceRecording()
voiceRecordingPlayer.removeListener(playerListener)
}
}
fun download() {
@ -174,17 +203,24 @@ class ChatMessageContentData(
if (path.isNotEmpty()) {
Log.i("[Content] Found displayable content: $path")
val isVoiceRecord = content.isVoiceRecording
filePath.value = path
isImage.value = FileUtils.isExtensionImage(path)
isVideo.value = FileUtils.isExtensionVideo(path)
isAudio.value = FileUtils.isExtensionAudio(path)
isAudio.value = FileUtils.isExtensionAudio(path) && !isVoiceRecord
isPdf.value = FileUtils.isExtensionPdf(path)
isVoiceRecording.value = isVoiceRecord
if (isVoiceRecord) {
val duration = content.fileDuration// duration is in ms
voiceRecordDuration.value = duration
formattedDuration.value = SimpleDateFormat("mm:ss", Locale.getDefault()).format(duration)
Log.i("[Voice Recording] Duration is ${voiceRecordDuration.value} ($duration)")
}
if (isVideo.value == true) {
scope.launch {
withContext(Dispatchers.IO) {
videoPreview.postValue(ImageUtils.getVideoPreview(path))
}
videoPreview.postValue(ImageUtils.getVideoPreview(path))
}
}
} else {
@ -193,6 +229,7 @@ class ChatMessageContentData(
isVideo.value = false
isAudio.value = false
isPdf.value = false
isVoiceRecording.value = false
}
} else {
downloadable.value = true
@ -200,13 +237,92 @@ class ChatMessageContentData(
isVideo.value = FileUtils.isExtensionVideo(fileName.value!!)
isAudio.value = FileUtils.isExtensionAudio(fileName.value!!)
isPdf.value = FileUtils.isExtensionPdf(fileName.value!!)
isVoiceRecording.value = false
}
isGenericFile.value = !isPdf.value!! && !isAudio.value!! && !isVideo.value!! && !isImage.value!!
isGenericFile.value = !isPdf.value!! && !isAudio.value!! && !isVideo.value!! && !isImage.value!! && !isVoiceRecording.value!!
downloadEnabled.value = !chatMessage.isFileTransferInProgress
downloadProgressInt.value = 0
downloadProgressString.value = "0%"
}
/** Voice recording specifics */
fun playVoiceRecording() {
Log.i("[Voice Recording] Playing voice record")
if (isPlayerClosed()) {
Log.w("[Voice Recording] Player closed, let's open it first")
initVoiceRecordPlayer()
}
voiceRecordingPlayer.start()
isVoiceRecordPlaying.value = true
tickerFlow().onEach {
voiceRecordPlayingPosition.postValue(voiceRecordingPlayer.currentPosition)
}.launchIn(scope)
}
fun pauseVoiceRecording() {
Log.i("[Voice Recording] Pausing voice record")
if (!isPlayerClosed()) {
voiceRecordingPlayer.pause()
}
isVoiceRecordPlaying.value = false
}
private fun tickerFlow() = flow {
while (isVoiceRecordPlaying.value == true) {
emit(Unit)
delay(100)
}
}
private fun initVoiceRecordPlayer() {
Log.i("[Voice Recording] Creating player for voice record")
// Use speaker sound card to play recordings, otherwise use earpiece
// If none are available, default one will be used
var speakerCard: String? = null
var earpieceCard: String? = null
for (device in coreContext.core.audioDevices) {
if (device.hasCapability(AudioDevice.Capabilities.CapabilityPlay)) {
if (device.type == AudioDevice.Type.Speaker) {
speakerCard = device.id
} else if (device.type == AudioDevice.Type.Earpiece) {
earpieceCard = device.id
}
}
}
val localPlayer = coreContext.core.createLocalPlayer(speakerCard ?: earpieceCard, null, null)
if (localPlayer != null) {
voiceRecordingPlayer = localPlayer
} else {
Log.e("[Voice Recording] Couldn't create local player!")
return
}
voiceRecordingPlayer.addListener(playerListener)
val content = getContent()
val path = if (content.isFileEncrypted) content.plainFilePath else content.filePath ?: ""
voiceRecordingPlayer.open(path.orEmpty())
voiceRecordDuration.value = voiceRecordingPlayer.duration
formattedDuration.value = SimpleDateFormat("mm:ss", Locale.getDefault()).format(voiceRecordingPlayer.duration) // is already in milliseconds
Log.i("[Voice Recording] Duration is ${voiceRecordDuration.value} (${voiceRecordingPlayer.duration})")
}
private fun stopVoiceRecording() {
if (!isPlayerClosed()) {
Log.i("[Voice Recording] Stopping voice record")
pauseVoiceRecording()
voiceRecordingPlayer.seek(0)
voiceRecordPlayingPosition.value = 0
voiceRecordingPlayer.close()
}
}
private fun isPlayerClosed(): Boolean {
return !this::voiceRecordingPlayer.isInitialized || voiceRecordingPlayer.state == Player.State.Closed
}
}
interface OnContentClickedListener {

View file

@ -28,6 +28,7 @@ import android.os.Parcelable
import android.provider.MediaStore
import android.view.MenuInflater
import android.view.MenuItem
import android.view.MotionEvent
import android.view.View
import androidx.activity.addCallback
import androidx.appcompat.view.menu.MenuBuilder
@ -201,6 +202,13 @@ class DetailChatRoomFragment : MasterFragment<ChatRoomDetailFragmentBinding, Cha
chatSendingViewModel.onTextToSendChanged(it)
})
chatSendingViewModel.requestRecordAudioPermissionEvent.observe(viewLifecycleOwner, {
it.consume {
Log.i("[Chat Room] Asking for RECORD_AUDIO permission")
requestPermissions(arrayOf(android.Manifest.permission.RECORD_AUDIO), 2)
}
})
listViewModel.events.observe(viewLifecycleOwner, { events ->
adapter.submitList(events)
})
@ -363,16 +371,28 @@ class DetailChatRoomFragment : MasterFragment<ChatRoomDetailFragmentBinding, Cha
}
}
binding.setSendMessageClickListener {
chatSendingViewModel.sendMessage()
binding.message.text?.clear()
}
binding.setStartCallClickListener {
val address = viewModel.addressToCall
if (address != null) {
coreContext.startCall(address)
binding.setVoiceRecordingTouchListener { _, event ->
if (corePreferences.holdToRecordVoiceMessage) {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
Log.i("[Chat Room] Start recording voice message as long as recording button is held")
chatSendingViewModel.startVoiceRecording()
}
MotionEvent.ACTION_UP -> {
val voiceRecordingDuration = chatSendingViewModel.voiceRecordingDuration.value ?: 0
if (voiceRecordingDuration < 1000) {
Log.w("[Chat Room] Voice recording button has been held for less than a second, considering miss click")
chatSendingViewModel.cancelVoiceRecording()
(requireActivity() as MainActivity).showSnackBar(R.string.chat_message_voice_recording_hold_to_record)
} else {
Log.i("[Chat Room] Voice recording button has been released, stop recording")
chatSendingViewModel.stopVoiceRecording()
}
}
}
true
}
false
}
if (textToShare?.isNotEmpty() == true) {
@ -408,13 +428,21 @@ class DetailChatRoomFragment : MasterFragment<ChatRoomDetailFragmentBinding, Cha
permissions: Array<out String>,
grantResults: IntArray
) {
if (requestCode == 0) {
var atLeastOneGranted = false
for (result in grantResults) {
atLeastOneGranted = atLeastOneGranted || result == PackageManager.PERMISSION_GRANTED
var atLeastOneGranted = false
for (result in grantResults) {
atLeastOneGranted = atLeastOneGranted || result == PackageManager.PERMISSION_GRANTED
}
when (requestCode) {
0 -> {
if (atLeastOneGranted) {
pickFile()
}
}
if (atLeastOneGranted) {
pickFile()
2 -> {
if (atLeastOneGranted) {
chatSendingViewModel.startVoiceRecording()
}
}
}
}

View file

@ -23,14 +23,22 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
import kotlin.collections.ArrayList
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.activities.main.chat.data.ChatMessageAttachmentData
import org.linphone.activities.main.chat.data.ChatMessageData
import org.linphone.core.ChatMessage
import org.linphone.core.ChatRoom
import org.linphone.core.ChatRoomCapabilities
import org.linphone.core.Factory
import org.linphone.core.*
import org.linphone.core.tools.Log
import org.linphone.utils.Event
import org.linphone.utils.FileUtils
import org.linphone.utils.PermissionHelper
class ChatMessageSendingViewModelFactory(private val chatRoom: ChatRoom) :
ViewModelProvider.NewInstanceFactory() {
@ -58,21 +66,64 @@ class ChatMessageSendingViewModel(private val chatRoom: ChatRoom) : ViewModel()
var pendingChatMessageToReplyTo = MutableLiveData<ChatMessageData>()
val requestRecordAudioPermissionEvent: MutableLiveData<Event<Boolean>> by lazy {
MutableLiveData<Event<Boolean>>()
}
val voiceRecordingProgressBarMax = 10000
val isPendingVoiceRecord = MutableLiveData<Boolean>()
val isVoiceRecording = MutableLiveData<Boolean>()
val voiceRecordingDuration = MutableLiveData<Int>()
val formattedDuration = MutableLiveData<String>()
val isPlayingVoiceRecording = MutableLiveData<Boolean>()
val recorder: Recorder
val voiceRecordPlayingPosition = MutableLiveData<Int>()
private lateinit var voiceRecordingPlayer: Player
private val playerListener = PlayerListener {
Log.i("[Chat Message Sending] End of file reached")
stopVoiceRecordPlayer()
}
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
init {
attachments.value = arrayListOf()
attachFileEnabled.value = true
sendMessageEnabled.value = false
isReadOnly.value = chatRoom.hasBeenLeft()
val recorderParams = coreContext.core.createRecorderParams()
recorderParams.fileFormat = RecorderFileFormat.Wav
recorder = coreContext.core.createRecorder(recorderParams)
}
override fun onCleared() {
attachments.value.orEmpty().forEach(ChatMessageAttachmentData::destroy)
if (recorder.state != RecorderState.Closed) {
recorder.close()
}
if (this::voiceRecordingPlayer.isInitialized) {
stopVoiceRecordPlayer()
voiceRecordingPlayer.removeListener(playerListener)
}
scope.cancel()
super.onCleared()
}
fun onTextToSendChanged(value: String) {
sendMessageEnabled.value = value.isNotEmpty() || attachments.value?.isNotEmpty() ?: false
sendMessageEnabled.value = value.isNotEmpty() || attachments.value?.isNotEmpty() == true || isPendingVoiceRecord.value == true
if (value.isNotEmpty()) {
if (attachFileEnabled.value == true && !corePreferences.allowMultipleFilesAndTextInSameMessage) {
attachFileEnabled.value = false
@ -93,7 +144,7 @@ class ChatMessageSendingViewModel(private val chatRoom: ChatRoom) : ViewModel()
})
attachments.value = list
sendMessageEnabled.value = textToSend.value.orEmpty().isNotEmpty() || list.isNotEmpty()
sendMessageEnabled.value = textToSend.value.orEmpty().isNotEmpty() || list.isNotEmpty() || isPendingVoiceRecord.value == true
if (!corePreferences.allowMultipleFilesAndTextInSameMessage) {
attachFileEnabled.value = false
}
@ -106,7 +157,7 @@ class ChatMessageSendingViewModel(private val chatRoom: ChatRoom) : ViewModel()
attachment.destroy()
attachments.value = list
sendMessageEnabled.value = textToSend.value.orEmpty().isNotEmpty() || list.isNotEmpty()
sendMessageEnabled.value = textToSend.value.orEmpty().isNotEmpty() || list.isNotEmpty() || isPendingVoiceRecord.value == true
if (!corePreferences.allowMultipleFilesAndTextInSameMessage) {
attachFileEnabled.value = list.isEmpty()
}
@ -118,13 +169,33 @@ class ChatMessageSendingViewModel(private val chatRoom: ChatRoom) : ViewModel()
chatRoom.createReplyMessage(pendingMessageToReplyTo.chatMessage)
else
chatRoom.createEmptyMessage()
val isBasicChatRoom: Boolean = chatRoom.hasCapability(ChatRoomCapabilities.Basic.toInt())
var voiceRecord = false
if (isPendingVoiceRecord.value == true && recorder.file != null) {
val content = recorder.createContent()
if (content != null) {
Log.i("[Chat Message Sending] Voice recording content created, file name is ${content.name} and duration is ${content.fileDuration}")
message.addContent(content)
voiceRecord = true
} else {
Log.e("[Chat Message Sending] Voice recording content couldn't be created!")
}
isPendingVoiceRecord.value = false
isVoiceRecording.value = false
}
val toSend = textToSend.value
if (toSend != null && toSend.isNotEmpty()) {
message.addUtf8TextContent(toSend.trim())
if (voiceRecord && isBasicChatRoom) {
val textMessage: ChatMessage = chatRoom.createMessageFromUtf8(toSend.trim())
textMessage.send()
} else {
message.addUtf8TextContent(toSend.trim())
}
}
val isBasicChatRoom: Boolean = chatRoom.hasCapability(ChatRoomCapabilities.Basic.toInt())
var fileContent = false
for (attachment in attachments.value.orEmpty()) {
val content = Factory.instance().createContent()
@ -140,7 +211,7 @@ class ChatMessageSendingViewModel(private val chatRoom: ChatRoom) : ViewModel()
// Do not send file in the same message as the text in a BasicChatRoom
// and don't send multiple files in the same message if setting says so
if (isBasicChatRoom or (corePreferences.preventMoreThanOneFilePerMessage and fileContent)) {
if (isBasicChatRoom or (corePreferences.preventMoreThanOneFilePerMessage and (fileContent or voiceRecord))) {
val fileMessage: ChatMessage = chatRoom.createFileTransferMessage(content)
fileMessage.send()
} else {
@ -156,6 +227,7 @@ class ChatMessageSendingViewModel(private val chatRoom: ChatRoom) : ViewModel()
cancelReply()
attachments.value.orEmpty().forEach(ChatMessageAttachmentData::destroy)
attachments.value = arrayListOf()
textToSend.value = ""
}
fun transferMessage(chatMessage: ChatMessage) {
@ -167,4 +239,178 @@ class ChatMessageSendingViewModel(private val chatRoom: ChatRoom) : ViewModel()
pendingChatMessageToReplyTo.value?.destroy()
isPendingAnswer.value = false
}
private fun tickerFlowRecording() = flow {
while (recorder.state == RecorderState.Running) {
emit(Unit)
delay(100)
}
}
private fun tickerFlowPlaying() = flow {
while (voiceRecordingPlayer.state == Player.State.Playing) {
emit(Unit)
delay(100)
}
}
fun toggleVoiceRecording() {
if (corePreferences.holdToRecordVoiceMessage) {
// Disables click listener just in case, touch listener will be used instead
return
}
if (isVoiceRecording.value == true) {
stopVoiceRecording()
} else {
startVoiceRecording()
}
}
fun startVoiceRecording() {
if (!PermissionHelper.get().hasRecordAudioPermission()) {
requestRecordAudioPermissionEvent.value = Event(true)
return
}
when (recorder.state) {
RecorderState.Running -> Log.w("[Chat Message Sending] Recorder is already recording")
RecorderState.Paused -> {
Log.w("[Chat Message Sending] Recorder isn't closed, resuming recording")
recorder.start()
}
RecorderState.Closed -> {
val tempFileName = "voice-recording-${System.currentTimeMillis()}.wav"
val file = FileUtils.getFileStoragePath(tempFileName)
Log.w("[Chat Message Sending] Recorder is closed, starting recording in ${file.absoluteFile}")
recorder.open(file.absolutePath)
recorder.start()
}
else -> {}
}
val duration = recorder.duration
voiceRecordingDuration.value = duration
formattedDuration.value = SimpleDateFormat("mm:ss", Locale.getDefault()).format(duration) // duration is in ms
isPendingVoiceRecord.value = true
isVoiceRecording.value = true
sendMessageEnabled.value = true
tickerFlowRecording().onEach {
val duration = recorder.duration
voiceRecordingDuration.postValue(recorder.duration % voiceRecordingProgressBarMax)
formattedDuration.postValue(SimpleDateFormat("mm:ss", Locale.getDefault()).format(duration)) // duration is in ms
if (duration >= corePreferences.voiceRecordingMaxDuration) {
withContext(Dispatchers.Main) {
Log.w("[Chat Message Sending] Max duration for voice recording exceeded (${corePreferences.voiceRecordingMaxDuration}ms), stopping.")
stopVoiceRecording()
}
}
}.launchIn(scope)
}
fun cancelVoiceRecording() {
if (recorder.state != RecorderState.Closed) {
Log.i("[Chat Message Sending] Closing voice recorder")
recorder.close()
val path = recorder.file
if (path != null) {
Log.i("[Chat Message Sending] Deleting voice recording file: $path")
FileUtils.deleteFile(path)
}
}
isPendingVoiceRecord.value = false
isVoiceRecording.value = false
sendMessageEnabled.value = textToSend.value?.isNotEmpty() == true || attachments.value?.isNotEmpty() == true
}
fun stopVoiceRecording() {
if (recorder.state == RecorderState.Running) {
Log.i("[Chat Message Sending] Pausing / closing voice recorder")
recorder.pause()
recorder.close()
voiceRecordingDuration.value = recorder.duration
}
isVoiceRecording.value = false
if (corePreferences.sendVoiceRecordingRightAway) {
Log.i("[Chat Message Sending] Sending voice recording right away")
sendMessage()
}
}
fun playRecordedMessage() {
if (isPlayerClosed()) {
Log.w("[Chat Message Sending] Player closed, let's open it first")
initVoiceRecordPlayer()
}
voiceRecordingPlayer.start()
isPlayingVoiceRecording.value = true
tickerFlowPlaying().onEach {
voiceRecordPlayingPosition.postValue(voiceRecordingPlayer.currentPosition)
}.launchIn(scope)
}
fun pauseRecordedMessage() {
Log.i("[Chat Message Sending] Pausing voice record")
if (!isPlayerClosed()) {
voiceRecordingPlayer.pause()
}
isPlayingVoiceRecording.value = false
}
private fun initVoiceRecordPlayer() {
Log.i("[Chat Message Sending] Creating player for voice record")
// Use speaker sound card to play recordings, otherwise use earpiece
// If none are available, default one will be used
var speakerCard: String? = null
var earpieceCard: String? = null
for (device in coreContext.core.audioDevices) {
if (device.hasCapability(AudioDevice.Capabilities.CapabilityPlay)) {
if (device.type == AudioDevice.Type.Speaker) {
speakerCard = device.id
} else if (device.type == AudioDevice.Type.Earpiece) {
earpieceCard = device.id
}
}
}
val localPlayer = coreContext.core.createLocalPlayer(speakerCard ?: earpieceCard, null, null)
if (localPlayer != null) {
voiceRecordingPlayer = localPlayer
} else {
Log.e("[Chat Message Sending] Couldn't create local player!")
return
}
voiceRecordingPlayer.addListener(playerListener)
val path = recorder.file
if (path != null) {
voiceRecordingPlayer.open(path)
// Update recording duration using player value to ensure proper progress bar animation
voiceRecordingDuration.value = voiceRecordingPlayer.duration
}
}
private fun stopVoiceRecordPlayer() {
if (!isPlayerClosed()) {
Log.i("[Chat Message Sending] Stopping voice record")
voiceRecordingPlayer.pause()
voiceRecordingPlayer.seek(0)
voiceRecordPlayingPosition.value = 0
voiceRecordingPlayer.close()
}
isPlayingVoiceRecording.value = false
}
private fun isPlayerClosed(): Boolean {
return !this::voiceRecordingPlayer.isInitialized || voiceRecordingPlayer.state == Player.State.Closed
}
}

View file

@ -225,6 +225,13 @@ class ChatRoomViewModel(val chatRoom: ChatRoom) : ViewModel(), ContactDataInterf
}
}
fun startCall() {
val address = addressToCall
if (address != null) {
coreContext.startCall(address)
}
}
private fun formatLastMessage(msg: ChatMessage?): String {
if (msg == null) return ""

View file

@ -72,6 +72,14 @@ class ChatSettingsViewModel : GenericSettingsViewModel() {
}
val autoDownloadMaxSize = MutableLiveData<Int>()
val autoDownloadVoiceRecordingsListener = object : SettingListenerStub() {
override fun onBoolValueChanged(newValue: Boolean) {
core.isAutoDownloadVoiceRecordingsEnabled = newValue
autoDownloadVoiceRecordings.value = newValue
}
}
val autoDownloadVoiceRecordings = MutableLiveData<Boolean>()
val downloadedMediaPublicListener = object : SettingListenerStub() {
override fun onBoolValueChanged(newValue: Boolean) {
prefs.makePublicMediaFilesDownloaded = newValue
@ -141,6 +149,7 @@ class ChatSettingsViewModel : GenericSettingsViewModel() {
useInAppFileViewer.value = prefs.useInAppFileViewerForNonEncryptedFiles || prefs.vfsEnabled
hideNotificationContent.value = prefs.hideChatMessageContentInNotification
initAutoDownloadList()
autoDownloadVoiceRecordings.value = core.isAutoDownloadVoiceRecordingsEnabled
launcherShortcuts.value = prefs.chatRoomShortcuts
hideEmptyRooms.value = prefs.hideEmptyRooms
hideRoomsRemovedProxies.value = prefs.hideRoomsFromRemovedProxies

View file

@ -201,6 +201,20 @@ class CorePreferences constructor(private val context: Context) {
config.setBool("app", "chat_room_shortcuts", value)
}
/* Voice Recordings */
var voiceRecordingMaxDuration: Int
get() = config.getInt("app", "voice_recording_max_duration", 600000) // in ms
set(value) = config.setInt("app", "voice_recording_max_duration", value)
var holdToRecordVoiceMessage: Boolean
get() = config.getBool("app", "voice_recording_hold_and_release_mode", false)
set(value) = config.setBool("app", "voice_recording_hold_and_release_mode", value)
var sendVoiceRecordingRightAway: Boolean
get() = config.getBool("app", "voice_recording_send_right_away", false)
set(value) = config.setBool("app", "voice_recording_send_right_away", value)
/* Contacts */
var storePresenceInNativeContact: Boolean

View file

@ -20,7 +20,9 @@
package org.linphone.utils
import android.app.Activity
import android.content.*
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.text.format.Formatter.formatShortFileSize
import android.util.TypedValue
@ -85,6 +87,10 @@ class AppUtils {
)
}
fun dpToPixels(context: Context, dp: Float): Float {
return dp * context.resources.displayMetrics.density
}
fun bytesToDisplayableSize(bytes: Long): String {
return formatShortFileSize(coreContext.context, bytes)
}

View file

@ -50,6 +50,7 @@ import org.linphone.activities.GenericActivity
import org.linphone.activities.main.settings.SettingListener
import org.linphone.contact.ContactAvatarView
import org.linphone.core.tools.Log
import org.linphone.views.VoiceRecordProgressBar
/**
* This file contains all the data binding necessary for the app
@ -532,3 +533,23 @@ fun setEditTextErrorListener(editText: EditText, attrChange: InverseBindingListe
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
})
}
@BindingAdapter("app:max")
fun VoiceRecordProgressBar.setProgressMax(max: Int) {
setMax(max)
}
@BindingAdapter("android:progress")
fun VoiceRecordProgressBar.setPrimaryProgress(progress: Int) {
setProgress(progress)
}
@BindingAdapter("android:secondaryProgress")
fun VoiceRecordProgressBar.setSecProgress(progress: Int) {
setSecondaryProgress(progress)
}
@BindingAdapter("app:secondaryProgressTint")
fun VoiceRecordProgressBar.setSecProgressTint(color: Int) {
setSecondaryProgressTint(color)
}

View file

@ -0,0 +1,333 @@
/*
* Copyright (c) 2010-2021 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* 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 org.linphone.views
import android.content.Context
import android.graphics.Canvas
import android.graphics.Rect
import android.graphics.drawable.*
import android.os.Parcel
import android.os.Parcelable
import android.util.AttributeSet
import android.view.View
import org.linphone.R
class VoiceRecordProgressBar : View {
companion object {
const val MAX_LEVEL = 10000
}
private var minWidth = 0
private var maxWidth = 0
private var minHeight = 0
private var maxHeight = 0
private var progress = 0
private var secondaryProgress = 0
private var max = 0
private var progressDrawable: Drawable? = null
private var primaryLeftMargin: Float = 0f
private var primaryRightMargin: Float = 0f
constructor(context: Context) : this(context, null)
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
constructor(context: Context, attrs: AttributeSet?, defStyle: Int) : super(context, attrs, defStyle) {
max = 100
progress = 0
secondaryProgress = 0
minWidth = 24
maxWidth = 48
minHeight = 24
maxHeight = 48
context.theme.obtainStyledAttributes(
attrs,
R.styleable.VoiceRecordProgressBar,
0, 0).apply {
try {
val drawable = getDrawable(R.styleable.VoiceRecordProgressBar_progressDrawable)
if (drawable != null) {
setProgressDrawable(drawable)
}
setPrimaryLeftMargin(getDimension(R.styleable.VoiceRecordProgressBar_primaryLeftMargin, 0f))
setPrimaryRightMargin(getDimension(R.styleable.VoiceRecordProgressBar_primaryRightMargin, 0f))
setMax(getInteger(R.styleable.VoiceRecordProgressBar_max, 100))
} finally {
recycle()
}
}
setProgress(0)
setSecondaryProgress(0)
}
override fun onSaveInstanceState(): Parcelable {
val superState: Parcelable? = super.onSaveInstanceState()
val savedState = SavedState(superState)
savedState.max = max
savedState.progress = progress
savedState.secondaryProgress = secondaryProgress
return savedState
}
override fun onRestoreInstanceState(state: Parcelable) {
val savedState = state as SavedState
super.onRestoreInstanceState(savedState.superState)
setMax(savedState.max)
setProgress(savedState.progress)
setSecondaryProgress(savedState.secondaryProgress)
}
override fun drawableStateChanged() {
super.drawableStateChanged()
updateDrawableState()
}
override fun invalidateDrawable(drawable: Drawable) {
if (verifyDrawable(drawable)) {
val dirty: Rect = drawable.bounds
val scrollX: Int = scrollX + paddingLeft
val scrollY: Int = scrollY + paddingTop
invalidate(
dirty.left + scrollX, dirty.top + scrollY,
dirty.right + scrollX, dirty.bottom + scrollY
)
} else {
super.invalidateDrawable(drawable)
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val drawable: Drawable? = progressDrawable
var dw = 0
var dh = 0
if (drawable != null) {
dw = minWidth.coerceAtLeast(maxWidth.coerceAtMost(drawable.intrinsicWidth))
dh = minHeight.coerceAtLeast(maxHeight.coerceAtMost(drawable.intrinsicHeight))
}
updateDrawableState()
dw += paddingRight + paddingLeft
dh += paddingBottom + paddingTop
setMeasuredDimension(
resolveSizeAndState(dw, widthMeasureSpec, 0),
resolveSizeAndState(dh, heightMeasureSpec, 0)
)
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
updateDrawableBounds(w, h)
}
override fun verifyDrawable(who: Drawable): Boolean {
return who === progressDrawable || super.verifyDrawable(who)
}
override fun jumpDrawablesToCurrentState() {
super.jumpDrawablesToCurrentState()
progressDrawable?.jumpToCurrentState()
}
private fun setPrimaryLeftMargin(margin: Float) {
primaryLeftMargin = margin
}
private fun setPrimaryRightMargin(margin: Float) {
primaryRightMargin = margin
}
fun setProgress(p: Int) {
var progress = p
if (progress < 0) {
progress = 0
}
if (progress > max) {
progress = max
}
if (progress != this.progress) {
this.progress = progress
refreshProgress(android.R.id.progress, this.progress)
}
}
fun setSecondaryProgress(sp: Int) {
var secondaryProgress = sp
if (secondaryProgress < 0) {
secondaryProgress = 0
}
if (secondaryProgress > max) {
secondaryProgress = max
}
if (secondaryProgress != this.secondaryProgress) {
this.secondaryProgress = secondaryProgress
refreshProgress(android.R.id.secondaryProgress, this.secondaryProgress)
}
}
fun setMax(m: Int) {
var max = m
if (max < 0) {
max = 0
}
if (max != this.max) {
this.max = max
postInvalidate()
if (progress > max) {
progress = max
}
refreshProgress(android.R.id.progress, progress)
}
}
fun setProgressDrawable(drawable: Drawable) {
val needUpdate: Boolean = if (progressDrawable != null && drawable !== progressDrawable) {
progressDrawable?.callback = null
true
} else {
false
}
drawable.callback = this
// Make sure the ProgressBar is always tall enough
val drawableHeight = drawable.minimumHeight
if (maxHeight < drawableHeight) {
maxHeight = drawableHeight
requestLayout()
}
progressDrawable = drawable
postInvalidate()
if (needUpdate) {
updateDrawableBounds(width, height)
updateDrawableState()
refreshProgress(android.R.id.progress, progress)
refreshProgress(android.R.id.secondaryProgress, secondaryProgress)
}
}
fun setSecondaryProgressTint(color: Int) {
val drawable = progressDrawable
if (drawable != null) {
if (drawable is LayerDrawable) {
val secondaryProgressDrawable = drawable.findDrawableByLayerId(android.R.id.secondaryProgress)
secondaryProgressDrawable?.setTint(color)
}
}
}
private fun refreshProgress(id: Int, progress: Int) {
var scale: Float = if (max > 0) (progress.toFloat() / max) else 0f
if (id == android.R.id.progress && scale > 0) {
if (width > 0) {
// Wait for secondaryProgress to have reached primaryLeftMargin to start primaryProgress at 0
val leftOffset = primaryLeftMargin / width
if (scale < leftOffset) return
// Prevent primaryProgress to go further than (width - rightMargin)
val rightOffset = primaryRightMargin / width
if (scale > 1 - rightOffset) {
scale = 1 - rightOffset
}
// Remove left margin from primary progress
scale -= leftOffset
// Since we use setBounds() to apply margins to the Bitmaps,
// the width of the bitmap is reduced so we have to adapt the level
val widthScale = width - (primaryLeftMargin + primaryRightMargin)
scale = ((scale * width) / widthScale)
}
}
val drawable: Drawable? = progressDrawable
if (drawable != null) {
var progressDrawable: Drawable? = null
if (drawable is LayerDrawable) {
progressDrawable = drawable.findDrawableByLayerId(id)
}
(progressDrawable ?: drawable).level = (scale * MAX_LEVEL).toInt()
} else {
invalidate()
}
}
private fun updateDrawableState() {
val state = drawableState
if (progressDrawable != null && progressDrawable?.isStateful == true) {
progressDrawable?.state = state
}
}
private fun updateDrawableBounds(w: Int, h: Int) {
val right: Int = w - paddingRight - paddingLeft
val bottom: Int = h - paddingBottom - paddingTop
progressDrawable?.setBounds(0, 0, right, bottom)
}
@Synchronized
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val drawable = progressDrawable as? LayerDrawable
if (drawable != null) {
canvas.save()
canvas.translate(paddingLeft.toFloat(), paddingTop.toFloat())
for (i in 0 until drawable.numberOfLayers) {
val drawableLayer = drawable.getDrawable(i)
if (i != 1) {
canvas.translate(primaryLeftMargin, 0f)
drawableLayer.draw(canvas)
drawableLayer.setBounds(0, 0, width - primaryRightMargin.toInt() - primaryLeftMargin.toInt(), height)
canvas.translate(-primaryLeftMargin, 0f)
} else {
drawableLayer.draw(canvas)
}
}
canvas.restore()
}
}
internal class SavedState(superState: Parcelable?) : BaseSavedState(superState) {
var max = 0
var progress = 0
var secondaryProgress = 0
override fun writeToParcel(output: Parcel, flags: Int) {
super.writeToParcel(output, flags)
output.writeInt(max)
output.writeInt(progress)
output.writeInt(secondaryProgress)
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 924 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 670 B

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 628 B

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 915 B

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="?attr/backgroundColor"/>
<solid android:color="@color/white_color"/>
<corners android:radius="@dimen/chat_message_round_corner_radius"/>
<padding android:left="0dp" android:top="0dp" android:right="0dp" android:bottom="0dp" />
</shape>

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@android:id/background"
android:gravity="fill_horizontal">
<shape android:shape="rectangle" android:tint="@color/white_color">
<corners android:radius="@dimen/chat_message_round_corner_radius"/>
<size android:height="40dp" />
</shape>
</item>
<item android:id="@android:id/secondaryProgress"
android:gravity="center_vertical|fill_horizontal">
<scale android:scaleWidth="100%">
<shape android:shape="rectangle" android:tint="@color/green_color">
<corners android:radius="@dimen/chat_message_round_corner_radius"/>
<size android:height="40dp" />
</shape>
</scale>
</item>
<item android:id="@android:id/progress"
android:gravity="fill_horizontal">
<bitmap android:src="@drawable/audio_recording_default"
android:gravity="center_vertical"
android:tileModeX="repeat"/>
</item>
</layer-list>

View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@android:id/background"
android:gravity="center_vertical|fill_horizontal">
<bitmap android:src="@drawable/audio_recording_default"
android:tint="@color/dark_grey_color"
android:tileModeX="repeat"/>
</item>
<item android:id="@android:id/secondaryProgress"
android:gravity="center_vertical|fill_horizontal">
<scale android:scaleWidth="100%">
<shape android:shape="rectangle" android:tint="@color/chat_bubble_incoming_color_dark">
<corners android:radius="@dimen/chat_message_round_corner_radius"/>
<size android:height="40dp"/>
</shape>
</scale>
</item>
<item android:id="@android:id/progress"
android:gravity="center_vertical|fill_horizontal">
<scale android:scaleWidth="100%">
<bitmap android:src="@drawable/audio_recording_default"
android:tint="@color/white_color"
android:tileModeX="repeat"/>
</scale>
</item>
</layer-list>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/white_color"/>
<corners android:radius="@dimen/chat_message_round_corner_radius"/>
<padding android:left="0dp" android:top="0dp" android:right="0dp" android:bottom="0dp" />
</shape>

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<bitmap android:src="@drawable/record_audio_message_default"
android:tint="?attr/drawableTintOverColor"/>
</item>
<item android:state_selected="true">
<bitmap android:src="@drawable/record_audio_message_default"
android:tint="?attr/drawableTintOverColor"/>
</item>
<item android:state_enabled="false">
<bitmap android:src="@drawable/record_audio_message_default"
android:tint="?attr/drawableTintDisabledColor"/>
</item>
<item>
<bitmap android:src="@drawable/record_audio_message_default"
android:tint="?attr/drawableTintColor"/>
</item>
</selector>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:src="@drawable/record_pause_default"
android:tint="@color/dark_grey_color"/>
</item>
</selector>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:src="@drawable/record_pause_default"
android:tint="@color/toolbar_color"/>
</item>
</selector>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:src="@drawable/record_play_default"
android:tint="@color/dark_grey_color"/>
</item>
</selector>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:src="@drawable/record_play_default"
android:tint="@color/toolbar_color"/>
</item>
</selector>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:src="@drawable/record_stop_default"
android:tint="@color/dark_grey_color"/>
</item>
</selector>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:src="@drawable/record_stop_default"
android:tint="@color/toolbar_color"/>
</item>
</selector>

View file

@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_selected="true">
<bitmap android:src="@drawable/record_pause"
<bitmap android:src="@drawable/record_pause_default"
android:tint="?attr/accentColor"/>
</item>
<item>
<bitmap android:src="@drawable/record_play"
<bitmap android:src="@drawable/record_play_default"
android:tint="?attr/accentColor"/>
</item>
</selector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="@color/white_color"/>
<size android:width="30dp" android:height="30dp"/>
</shape>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="@color/dark_grey_color"/>
<size android:width="30dp" android:height="30dp"/>
</shape>

View file

@ -39,9 +39,11 @@
<ImageView
android:visibility="@{data.video ? View.VISIBLE : View.GONE, default=gone}"
android:layout_width="20dp"
android:layout_height="20dp"
android:src="@drawable/recording_play_pause"
android:layout_width="@dimen/play_pause_button_size"
android:layout_height="@dimen/play_pause_button_size"
android:padding="9dp"
android:src="@drawable/record_play_dark"
android:background="@drawable/round_recording_button_background_dark"
android:contentDescription="@string/content_description_chat_message_video_attachment"
android:layout_centerInParent="true"/>

View file

@ -27,7 +27,7 @@
android:maxHeight="@dimen/chat_message_bubble_image_height_big"
android:layout_size="@{data.alone ? 0f : @dimen/chat_message_bubble_file_size}"
app:glidePath="@{data.filePath}"
android:visibility="@{data.image ? View.VISIBLE : View.GONE}"
android:visibility="@{!data.downloadable &amp;&amp; data.image ? View.VISIBLE : View.GONE}"
android:scaleType="@{data.alone ? ScaleType.FIT_CENTER : ScaleType.CENTER_CROP}"
android:adjustViewBounds="true" />
@ -40,25 +40,32 @@
android:maxHeight="@dimen/chat_message_bubble_image_height_big"
android:layout_size="@{data.alone ? 0f : @dimen/chat_message_bubble_file_size}"
android:src="@{data.videoPreview}"
android:visibility="@{data.video ? View.VISIBLE : View.GONE}"
android:visibility="@{!data.downloadable &amp;&amp; data.video ? View.VISIBLE : View.GONE}"
android:scaleType="@{data.alone ? ScaleType.FIT_CENTER : ScaleType.CENTER_CROP}"
android:adjustViewBounds="true" />
<ImageView
android:visibility="@{data.video ? View.VISIBLE : View.GONE}"
android:layout_width="20dp"
android:layout_height="20dp"
android:src="@drawable/recording_play_pause"
android:visibility="@{!data.downloadable &amp;&amp; data.video ? View.VISIBLE : View.GONE}"
android:layout_width="@dimen/play_pause_button_size"
android:layout_height="@dimen/play_pause_button_size"
android:padding="9dp"
android:src="@drawable/record_play_dark"
android:background="@drawable/round_recording_button_background_dark"
android:contentDescription="@string/content_description_chat_message_video_attachment"
android:layout_centerInParent="true"/>
<include layout="@layout/chat_message_voice_record_content_cell"
app:data="@{data}"
app:longClickListener="@{longClickListener}"
android:visibility="@{!data.downloadable &amp;&amp; data.voiceRecording ? View.VISIBLE : View.GONE, default=gone}" />
<LinearLayout
android:layout_width="@dimen/chat_message_bubble_file_size"
android:layout_height="@dimen/chat_message_bubble_file_size"
android:orientation="vertical"
android:gravity="center"
android:background="?attr/backgroundColor"
android:visibility="@{data.downloadable || data.pdf || data.audio || data.genericFile ? View.VISIBLE : View.GONE}">
android:visibility="@{data.downloadable || data.audio || data.pdf || data.genericFile ? View.VISIBLE : View.GONE}">
<TextView
style="@style/chat_file_attachment_font"
@ -71,7 +78,7 @@
android:gravity="center"
android:textAlignment="center"
android:drawablePadding="5dp"
android:drawableTop="@{data.video ? @drawable/file_video : (data.image ? @drawable/file_picture : (data.pdf ? @drawable/file_pdf : (data.audio ? @drawable/file_audio : @drawable/file))), default=@drawable/file}"
android:drawableTop="@{data.video ? @drawable/file_video : (data.image ? @drawable/file_picture : (data.pdf ? @drawable/file_pdf : (data.audio ? @drawable/file_audio : (data.voiceRecording ? @drawable/audio_recording_reply_preview_default : @drawable/file)))), default=@drawable/file}"
android:text="@{data.fileName, default=`test.pdf`}"
android:onClick="@{() -> data.downloadable ? data.download() : data.openFile()}"
android:onLongClick="@{longClickListener}"/>
@ -89,7 +96,7 @@
<RelativeLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="@{data.downloadProgressInt > 0 ? View.VISIBLE : View.GONE}"
android:visibility="@{data.downloadProgressInt > 0 ? View.VISIBLE : View.GONE, default=gone}"
android:layout_centerInParent="true">
<com.google.android.material.progressindicator.CircularProgressIndicator

View file

@ -15,13 +15,14 @@
<RelativeLayout
android:background="@{data.chatMessage.isOutgoing ? @color/chat_bubble_outgoing_color : @color/chat_bubble_incoming_color, default=@color/chat_bubble_incoming_color}"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="10dp"
android:paddingBottom="10dp">
android:layout_height="wrap_content">
<LinearLayout
android:id="@+id/reply_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:layout_marginLeft="10dp"
android:orientation="vertical"
android:layout_toLeftOf="@id/clear_reply">
@ -42,6 +43,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
app:entries="@{data.contents}"
app:layout="@{@layout/chat_message_reply_preview_content_cell}"/>
@ -60,6 +62,7 @@
<ImageView
android:id="@+id/clear_reply"
android:onClick="@{cancelClickListener}"
android:contentDescription="@string/content_description_cancel_reply"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_alignParentRight="true"
@ -69,6 +72,12 @@
android:padding="5dp"
android:src="@drawable/field_clean" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?attr/dividerColor"
android:layout_below="@id/reply_layout"/>
</RelativeLayout>
</layout>

View file

@ -39,12 +39,24 @@
<ImageView
android:visibility="@{data.video ? View.VISIBLE : View.GONE}"
android:layout_width="20dp"
android:layout_height="20dp"
android:src="@drawable/recording_play_pause"
android:layout_width="@dimen/play_pause_button_size"
android:layout_height="@dimen/play_pause_button_size"
android:padding="9dp"
android:src="@drawable/record_play_dark"
android:background="@drawable/round_recording_button_background_dark"
android:contentDescription="@string/content_description_chat_message_video_attachment"
android:layout_centerInParent="true"/>
<TextView
android:visibility="@{data.isVoiceRecording ? View.VISIBLE : View.GONE}"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:drawablePadding="2dp"
android:gravity="center"
android:text="@{data.formattedDuration, default=`00:00`}"
android:textColor="@color/light_primary_text_color"
android:drawableTop="@drawable/audio_recording_reply_preview_default"/>
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"

View file

@ -12,13 +12,13 @@
<RelativeLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="5dp">
android:layout_height="wrap_content">
<ImageView
android:contentDescription="@string/content_description_downloaded_file_transfer"
android:layout_width="@dimen/chat_message_small_bubble_file_size"
android:layout_height="@dimen/chat_message_small_bubble_file_size"
android:layout_margin="5dp"
app:glidePath="@{data.filePath}"
android:visibility="@{data.image ? View.VISIBLE : View.GONE}"
android:scaleType="@{ScaleType.CENTER_CROP}"
@ -28,6 +28,7 @@
android:contentDescription="@string/content_description_downloaded_file_transfer"
android:layout_width="@dimen/chat_message_small_bubble_file_size"
android:layout_height="@dimen/chat_message_small_bubble_file_size"
android:layout_margin="5dp"
android:src="@{data.videoPreview}"
android:visibility="@{data.video ? View.VISIBLE : View.GONE}"
android:scaleType="@{ScaleType.CENTER_CROP}"
@ -35,15 +36,29 @@
<ImageView
android:visibility="@{data.video ? View.VISIBLE : View.GONE}"
android:layout_width="20dp"
android:layout_height="20dp"
android:src="@drawable/recording_play_pause"
android:layout_width="@dimen/play_pause_button_size"
android:layout_height="@dimen/play_pause_button_size"
android:layout_margin="5dp"
android:padding="9dp"
android:src="@drawable/record_play_dark"
android:background="@drawable/round_recording_button_background_dark"
android:contentDescription="@string/content_description_chat_message_video_attachment"
android:layout_centerInParent="true"/>
<TextView
android:visibility="@{data.isVoiceRecording ? View.VISIBLE : View.GONE}"
android:layout_width="wrap_content"
android:layout_height="@dimen/progress_bar_height"
android:drawablePadding="5dp"
android:gravity="center"
android:text="@{data.formattedDuration, default=`00:00`}"
android:textColor="@color/light_primary_text_color"
android:drawableLeft="@drawable/audio_recording_reply_preview_default"/>
<ImageView
android:layout_width="@dimen/chat_message_small_bubble_file_size"
android:layout_height="@dimen/chat_message_small_bubble_file_size"
android:layout_margin="5dp"
android:background="@drawable/chat_bubble_reply_file_background"
android:padding="10dp"
android:contentDescription="@{data.fileName}"

View file

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<import type="android.view.View"/>
<import type="android.widget.ImageView.ScaleType"/>
<variable
name="data"
type="org.linphone.activities.main.chat.data.ChatMessageContentData" />
<variable
name="longClickListener"
type="android.view.View.OnLongClickListener"/>
</data>
<RelativeLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:onLongClick="@{longClickListener}">
<org.linphone.views.VoiceRecordProgressBar
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="@dimen/progress_bar_height"
android:layout_centerVertical="true"
android:progress="@{data.voiceRecordPlayingPosition}"
android:secondaryProgress="@{data.voiceRecordPlayingPosition}"
app:max="@{data.voiceRecordDuration}"
app:progressDrawable="@drawable/chat_message_audio_record_progress"
app:secondaryProgressTint="@{data.outgoing ? @color/chat_bubble_outgoing_color_dark : @color/chat_bubble_incoming_color_dark}"
app:primaryLeftMargin="40dp"
app:primaryRightMargin="50dp"/>
<ImageView
android:id="@+id/play_voice_record"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_alignParentLeft="true"
android:layout_centerVertical="true"
android:layout_marginLeft="5dp"
android:layout_marginRight="5dp"
android:contentDescription="@string/content_description_play_voice_recording"
android:background="@drawable/round_recording_button_background_dark"
android:onClick="@{() -> data.isVoiceRecordPlaying() ? data.pauseVoiceRecording() : data.playVoiceRecording()}"
android:padding="9dp"
android:src="@{data.isVoiceRecordPlaying() ? @drawable/record_pause_dark : @drawable/record_play_dark, default=@drawable/record_play_dark}" />
<TextView
android:id="@+id/recording_duration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentRight="true"
android:layout_centerVertical="true"
android:layout_marginLeft="5dp"
android:layout_marginRight="5dp"
android:text="@{data.formattedDuration, default=`00:00`}"
android:textColor="@color/light_primary_text_color"
android:textSize="15sp" />
</RelativeLayout>
</layout>

View file

@ -0,0 +1,117 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<import type="android.view.View"/>
<variable
name="viewModel"
type="org.linphone.activities.main.chat.viewmodels.ChatMessageSendingViewModel" />
</data>
<RelativeLayout
android:background="?attr/backgroundColor2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="10dp"
android:paddingBottom="10dp">
<ImageView
android:id="@+id/cancel_recording"
android:onClick="@{() -> viewModel.cancelVoiceRecording()}"
android:contentDescription="@string/content_description_cancel_voice_recording"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_alignParentLeft="true"
android:layout_centerVertical="true"
android:layout_marginLeft="10dp"
android:layout_marginRight="10dp"
android:src="@drawable/delete" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_centerVertical="true"
android:gravity="center_vertical"
android:layout_toRightOf="@id/cancel_recording"
android:layout_toLeftOf="@id/play_pause_stop"
android:background="@drawable/chat_message_voice_recording_background">
<org.linphone.views.VoiceRecordProgressBar
android:visibility="@{viewModel.isVoiceRecording ? View.GONE : View.VISIBLE}"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="@dimen/progress_bar_height"
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:progress="@{viewModel.voiceRecordPlayingPosition}"
android:secondaryProgress="@{viewModel.voiceRecordPlayingPosition}"
app:max="@{viewModel.voiceRecordingDuration}"
app:progressDrawable="@drawable/chat_message_audio_record_progress"
app:secondaryProgressTint="@{@color/green_color}"/>
<ProgressBar
android:visibility="@{viewModel.isVoiceRecording ? View.VISIBLE : View.GONE}"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="@dimen/progress_bar_height"
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:progressDrawable="@drawable/chat_message_audio_record_preview_progress"
android:max="@{viewModel.voiceRecordingProgressBarMax}"
android:progress="@{viewModel.voiceRecordingDuration}"/>
<TextView
android:text="@{viewModel.formattedDuration, default=`00:00`}"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="5dp"
android:layout_marginRight="5dp"
android:textColor="@color/light_primary_text_color"
android:textSize="15sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/play_pause_stop"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginRight="10dp"
android:layout_marginLeft="10dp"
android:layout_centerVertical="true"
android:layout_alignParentRight="true">
<ImageView
android:visibility="@{viewModel.isVoiceRecording ? View.VISIBLE : View.GONE}"
android:onClick="@{() -> viewModel.stopVoiceRecording()}"
android:contentDescription="@string/content_description_stop_voice_recording"
android:layout_width="30dp"
android:layout_height="30dp"
android:padding="9dp"
android:background="@drawable/round_recording_button_background_light"
android:src="@drawable/record_stop_light" />
<ImageView
android:visibility="@{!viewModel.isVoiceRecording &amp;&amp; !viewModel.isPlayingVoiceRecording ? View.VISIBLE : View.GONE, default=gone}"
android:onClick="@{() -> viewModel.playRecordedMessage()}"
android:contentDescription="@string/content_description_play_voice_recording"
android:layout_width="30dp"
android:layout_height="30dp"
android:padding="9dp"
android:background="@drawable/round_recording_button_background_light"
android:src="@drawable/record_play_light" />
<ImageView
android:visibility="@{!viewModel.isVoiceRecording &amp;&amp; viewModel.isPlayingVoiceRecording ? View.VISIBLE : View.GONE, default=gone}"
android:onClick="@{() -> viewModel.pauseRecordedMessage()}"
android:contentDescription="@string/content_description_pause_voice_recording_playback"
android:layout_width="30dp"
android:layout_height="30dp"
android:padding="9dp"
android:background="@drawable/round_recording_button_background_light"
android:src="@drawable/record_pause_light" />
</LinearLayout>
</RelativeLayout>
</layout>

View file

@ -24,11 +24,8 @@
name="attachFileClickListener"
type="android.view.View.OnClickListener"/>
<variable
name="sendMessageClickListener"
type="android.view.View.OnClickListener"/>
<variable
name="startCallClickListener"
type="android.view.View.OnClickListener"/>
name="voiceRecordingTouchListener"
type="android.view.View.OnTouchListener" />
<variable
name="viewModel"
type="org.linphone.activities.main.chat.viewmodels.ChatRoomViewModel" />
@ -96,7 +93,7 @@
</LinearLayout>
<ImageView
android:onClick="@{startCallClickListener}"
android:onClick="@{() -> viewModel.startCall()}"
android:contentDescription="@string/content_description_start_call"
android:layout_width="0dp"
android:layout_height="match_parent"
@ -153,10 +150,21 @@
app:cancelClickListener="@{() -> chatSendingViewModel.cancelReply()}"
layout="@layout/chat_message_reply" />
<include
android:visibility="@{chatSendingViewModel.isPendingVoiceRecord ? View.VISIBLE : View.GONE, default=gone}"
app:viewModel="@{chatSendingViewModel}"
layout="@layout/chat_message_voice_recording" />
<View
android:visibility="@{chatSendingViewModel.isPendingVoiceRecord &amp;&amp; chatSendingViewModel.attachments.size() > 0 ? View.VISIBLE : View.GONE, default=gone}"
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?attr/dividerColor" />
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/lightToolbarBackgroundColor"
android:background="?attr/backgroundColor2"
android:orientation="horizontal">
<LinearLayout
@ -172,7 +180,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:background="@color/toolbar_color"
android:background="?attr/lightToolbarBackgroundColor"
android:layout_marginTop="5dp"
android:layout_marginBottom="5dp"
android:orientation="horizontal">
@ -183,9 +191,23 @@
android:contentDescription="@string/content_description_attach_file"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="5dp"
android:padding="10dp"
android:src="@drawable/chat_file" />
<ImageView
android:id="@+id/voice_record"
android:onTouch="@{voiceRecordingTouchListener}"
android:onClick="@{() -> chatSendingViewModel.toggleVoiceRecording()}"
android:enabled="@{!chatSendingViewModel.isReadOnly}"
android:selected="@{chatSendingViewModel.isVoiceRecording}"
android:contentDescription="@string/content_description_voice_recording"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:paddingRight="10dp"
android:src="@drawable/record_audio_message" />
<org.linphone.activities.main.chat.views.RichEditText
android:id="@+id/message"
android:enabled="@{!chatSendingViewModel.isReadOnly}"
@ -211,12 +233,12 @@
<ImageView
android:id="@+id/send_message"
android:onClick="@{sendMessageClickListener}"
android:onClick="@{() -> chatSendingViewModel.sendMessage()}"
android:enabled="@{chatSendingViewModel.sendMessageEnabled &amp;&amp; !chatSendingViewModel.isReadOnly}"
android:contentDescription="@string/content_description_send_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="5dp"
android:padding="10dp"
android:src="@drawable/chat_send_message" />
<ImageView

View file

@ -89,6 +89,12 @@
linphone:enabled="@{viewModel.autoDownloadIndex == 2}"
android:visibility="@{viewModel.autoDownloadIndex == 2 ? View.VISIBLE : View.GONE}"/>
<include
layout="@layout/settings_widget_switch"
linphone:title="@{@string/chat_settings_auto_download_voice_recordings}"
linphone:listener="@{viewModel.autoDownloadVoiceRecordingsListener}"
linphone:checked="@={viewModel.autoDownloadVoiceRecordings}"/>
<include
layout="@layout/settings_widget_switch"
linphone:title="@{@string/chat_settings_downloaded_media_public_title}"

View file

@ -41,4 +41,12 @@
<attr name="value" format="string" />
<attr name="image" format="reference" />
</declare-styleable>
<declare-styleable name="VoiceRecordProgressBar">
<attr name="max" format="integer" />
<attr name="progressDrawable" format="reference" />
<attr name="secondaryProgressTint" format="color" />
<attr name="primaryLeftMargin" format="dimension" />
<attr name="primaryRightMargin" format="dimension" />
</declare-styleable>
</resources>

View file

@ -28,4 +28,6 @@
<dimen name="chat_message_popup_width">250dp</dimen>
<dimen name="chat_message_popup_item_height">50dp</dimen>
<dimen name="chat_message_round_corner_radius">6.7dp</dimen>
<dimen name="play_pause_button_size">30dp</dimen>
<dimen name="progress_bar_height">40dp</dimen>
</resources>

View file

@ -218,6 +218,7 @@
<string name="chat_message_cant_open_file_in_app_dialog_open_as_text_button">Open as text</string>
<string name="chat_room_sending_reply_hint">Reply</string>
<string name="chat_room_sending_message_hint">Message</string>
<string name="chat_message_voice_recording_hold_to_record">Hold button to record voice message</string>
<!-- Recordings -->
<string name="recordings_empty_list">No recordings</string>
@ -443,6 +444,7 @@
<string name="chat_settings_use_in_app_file_viewer_title">Always open files inside this app</string>
<string name="chat_settings_use_in_app_file_viewer_summary">You\'ll still be able to export them in third-party apps</string>
<string name="chat_settins_enable_ephemeral_messages_beta_title">Enable ephemeral messages (beta)</string>
<string name="chat_settings_auto_download_voice_recordings">Auto download incoming voice recordings</string>
<!-- Network settings -->
<string name="network_settings_wifi_only_title">Use WiFi only</string>
@ -643,6 +645,7 @@
<string name="content_description_attach_file">Attach a file to the message</string>
<string name="content_description_attached_file">File attached to the message</string>
<string name="content_description_send_message">Send message</string>
<string name="content_description_record_audio_message">Record audio message</string>
<string name="content_description_toggle_participant_devices_list">Show or hide the participant devices</string>
<string name="content_description_ephemeral_duration_selected">Ephemeral duration selected</string>
<string name="content_description_ephemeral_duration_change">Change ephemeral duration by selected value</string>
@ -715,4 +718,10 @@
<string name="content_description_export">Open file in third-party app</string>
<string name="content_description_cancel_forward">Cancel message forward</string>
<string name="content_description_cancel_sharing">Cancel sharing</string>
<string name="content_description_cancel_reply">Cancel reply</string>
<string name="content_description_voice_recording">Record a voice message</string>
<string name="content_description_stop_voice_recording">Stop voice recording</string>
<string name="content_description_cancel_voice_recording">Cancel voice recording</string>
<string name="content_description_pause_voice_recording_playback">Pause voice recording</string>
<string name="content_description_play_voice_recording">Play voice recording</string>
</resources>

View file

@ -114,13 +114,13 @@
<!-- Chat related -->
<style name="chat_message_reply_sender_font">
<item name="android:textColor">?attr/primaryTextColor</item>
<item name="android:textColor">@color/light_primary_text_color</item>
<item name="android:textSize">13sp</item>
<item name="android:fontFamily">sans-serif-medium</item>
</style>
<style name="chat_message_reply_font">
<item name="android:textColor">?attr/primaryTextColor</item>
<item name="android:textColor">@color/light_primary_text_color</item>
<item name="android:textSize">15sp</item>
<item name="android:fontFamily">sans-serif</item>
</style>

View file

@ -12,7 +12,7 @@ buildscript {
maven { url "https://www.jitpack.io" } // for com.github.chrisbanes:PhotoView
}
dependencies {
classpath 'com.android.tools.build:gradle:4.2.1'
classpath 'com.android.tools.build:gradle:4.2.2'
classpath 'com.google.gms:google-services:4.3.5'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "org.jlleitschuh.gradle:ktlint-gradle:9.1.1"