diff --git a/app/src/main/java/org/linphone/activities/main/chat/data/ChatMessageContentData.kt b/app/src/main/java/org/linphone/activities/main/chat/data/ChatMessageContentData.kt index 333d4cf72..a5447207e 100644 --- a/app/src/main/java/org/linphone/activities/main/chat/data/ChatMessageContentData.kt +++ b/app/src/main/java/org/linphone/activities/main/chat/data/ChatMessageContentData.kt @@ -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() val isVideo = MutableLiveData() @@ -49,6 +56,7 @@ class ChatMessageContentData( val videoPreview = MutableLiveData() val isPdf = MutableLiveData() val isGenericFile = MutableLiveData() + val isVoiceRecording = MutableLiveData() val fileName = MutableLiveData() val filePath = MutableLiveData() @@ -60,6 +68,11 @@ class ChatMessageContentData( val downloadProgressString = MutableLiveData() val downloadLabel = MutableLiveData() + val voiceRecordDuration = MutableLiveData() + val formattedDuration = MutableLiveData() + val voiceRecordPlayingPosition = MutableLiveData() + val isVoiceRecordPlaying = MutableLiveData() + 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 { diff --git a/app/src/main/java/org/linphone/activities/main/chat/fragments/DetailChatRoomFragment.kt b/app/src/main/java/org/linphone/activities/main/chat/fragments/DetailChatRoomFragment.kt index d4c564946..14d433001 100644 --- a/app/src/main/java/org/linphone/activities/main/chat/fragments/DetailChatRoomFragment.kt +++ b/app/src/main/java/org/linphone/activities/main/chat/fragments/DetailChatRoomFragment.kt @@ -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 adapter.submitList(events) }) @@ -363,16 +371,28 @@ class DetailChatRoomFragment : MasterFragment + 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, 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() + } } } } diff --git a/app/src/main/java/org/linphone/activities/main/chat/viewmodels/ChatMessageSendingViewModel.kt b/app/src/main/java/org/linphone/activities/main/chat/viewmodels/ChatMessageSendingViewModel.kt index 6d26ac077..e50088784 100644 --- a/app/src/main/java/org/linphone/activities/main/chat/viewmodels/ChatMessageSendingViewModel.kt +++ b/app/src/main/java/org/linphone/activities/main/chat/viewmodels/ChatMessageSendingViewModel.kt @@ -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() + val requestRecordAudioPermissionEvent: MutableLiveData> by lazy { + MutableLiveData>() + } + + val voiceRecordingProgressBarMax = 10000 + + val isPendingVoiceRecord = MutableLiveData() + + val isVoiceRecording = MutableLiveData() + + val voiceRecordingDuration = MutableLiveData() + + val formattedDuration = MutableLiveData() + + val isPlayingVoiceRecording = MutableLiveData() + + val recorder: Recorder + + val voiceRecordPlayingPosition = MutableLiveData() + + 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 + } } diff --git a/app/src/main/java/org/linphone/activities/main/chat/viewmodels/ChatRoomViewModel.kt b/app/src/main/java/org/linphone/activities/main/chat/viewmodels/ChatRoomViewModel.kt index eb186efa2..5b2a1d5ac 100644 --- a/app/src/main/java/org/linphone/activities/main/chat/viewmodels/ChatRoomViewModel.kt +++ b/app/src/main/java/org/linphone/activities/main/chat/viewmodels/ChatRoomViewModel.kt @@ -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 "" diff --git a/app/src/main/java/org/linphone/activities/main/settings/viewmodels/ChatSettingsViewModel.kt b/app/src/main/java/org/linphone/activities/main/settings/viewmodels/ChatSettingsViewModel.kt index 1967c38a1..d599278a0 100644 --- a/app/src/main/java/org/linphone/activities/main/settings/viewmodels/ChatSettingsViewModel.kt +++ b/app/src/main/java/org/linphone/activities/main/settings/viewmodels/ChatSettingsViewModel.kt @@ -72,6 +72,14 @@ class ChatSettingsViewModel : GenericSettingsViewModel() { } val autoDownloadMaxSize = MutableLiveData() + val autoDownloadVoiceRecordingsListener = object : SettingListenerStub() { + override fun onBoolValueChanged(newValue: Boolean) { + core.isAutoDownloadVoiceRecordingsEnabled = newValue + autoDownloadVoiceRecordings.value = newValue + } + } + val autoDownloadVoiceRecordings = MutableLiveData() + 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 diff --git a/app/src/main/java/org/linphone/core/CorePreferences.kt b/app/src/main/java/org/linphone/core/CorePreferences.kt index 8fdd03525..49345c7da 100644 --- a/app/src/main/java/org/linphone/core/CorePreferences.kt +++ b/app/src/main/java/org/linphone/core/CorePreferences.kt @@ -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 diff --git a/app/src/main/java/org/linphone/utils/AppUtils.kt b/app/src/main/java/org/linphone/utils/AppUtils.kt index 8e58e751b..0875e5139 100644 --- a/app/src/main/java/org/linphone/utils/AppUtils.kt +++ b/app/src/main/java/org/linphone/utils/AppUtils.kt @@ -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) } diff --git a/app/src/main/java/org/linphone/utils/DataBindingUtils.kt b/app/src/main/java/org/linphone/utils/DataBindingUtils.kt index db8b28770..3be9be1be 100644 --- a/app/src/main/java/org/linphone/utils/DataBindingUtils.kt +++ b/app/src/main/java/org/linphone/utils/DataBindingUtils.kt @@ -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) +} diff --git a/app/src/main/java/org/linphone/views/VoiceRecordProgressBar.kt b/app/src/main/java/org/linphone/views/VoiceRecordProgressBar.kt new file mode 100644 index 000000000..099c4461a --- /dev/null +++ b/app/src/main/java/org/linphone/views/VoiceRecordProgressBar.kt @@ -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 . + */ +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) + } + } +} diff --git a/app/src/main/res/drawable-xhdpi/audio_recording_default.png b/app/src/main/res/drawable-xhdpi/audio_recording_default.png new file mode 100644 index 000000000..6e800a6bc Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/audio_recording_default.png differ diff --git a/app/src/main/res/drawable-xhdpi/audio_recording_reply_preview_default.png b/app/src/main/res/drawable-xhdpi/audio_recording_reply_preview_default.png new file mode 100644 index 000000000..e4a9b2087 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/audio_recording_reply_preview_default.png differ diff --git a/app/src/main/res/drawable-xhdpi/chat_file_over.png b/app/src/main/res/drawable-xhdpi/chat_file_over.png index 3781846f9..720dd10cf 100644 Binary files a/app/src/main/res/drawable-xhdpi/chat_file_over.png and b/app/src/main/res/drawable-xhdpi/chat_file_over.png differ diff --git a/app/src/main/res/drawable-xhdpi/chat_send_over.png b/app/src/main/res/drawable-xhdpi/chat_send_over.png index cd1f69268..cce92a4b9 100644 Binary files a/app/src/main/res/drawable-xhdpi/chat_send_over.png and b/app/src/main/res/drawable-xhdpi/chat_send_over.png differ diff --git a/app/src/main/res/drawable-xhdpi/record_audio_message_default.png b/app/src/main/res/drawable-xhdpi/record_audio_message_default.png new file mode 100644 index 000000000..5f319e5c8 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/record_audio_message_default.png differ diff --git a/app/src/main/res/drawable-xhdpi/record_pause.png b/app/src/main/res/drawable-xhdpi/record_pause.png deleted file mode 100644 index d519d771f..000000000 Binary files a/app/src/main/res/drawable-xhdpi/record_pause.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/record_pause_default.png b/app/src/main/res/drawable-xhdpi/record_pause_default.png new file mode 100644 index 000000000..15e1af51f Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/record_pause_default.png differ diff --git a/app/src/main/res/drawable-xhdpi/record_play.png b/app/src/main/res/drawable-xhdpi/record_play.png deleted file mode 100644 index f3095fd98..000000000 Binary files a/app/src/main/res/drawable-xhdpi/record_play.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/record_play_default.png b/app/src/main/res/drawable-xhdpi/record_play_default.png new file mode 100644 index 000000000..134279fc0 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/record_play_default.png differ diff --git a/app/src/main/res/drawable-xhdpi/record_stop_default.png b/app/src/main/res/drawable-xhdpi/record_stop_default.png new file mode 100644 index 000000000..14f700c3d Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/record_stop_default.png differ diff --git a/app/src/main/res/drawable/chat_bubble_reply_background.xml b/app/src/main/res/drawable/chat_bubble_reply_background.xml index 5e5b435c7..3725abfdf 100644 --- a/app/src/main/res/drawable/chat_bubble_reply_background.xml +++ b/app/src/main/res/drawable/chat_bubble_reply_background.xml @@ -1,6 +1,6 @@ - + diff --git a/app/src/main/res/drawable/chat_message_audio_record_preview_progress.xml b/app/src/main/res/drawable/chat_message_audio_record_preview_progress.xml new file mode 100644 index 000000000..f6419f6e9 --- /dev/null +++ b/app/src/main/res/drawable/chat_message_audio_record_preview_progress.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/chat_message_audio_record_progress.xml b/app/src/main/res/drawable/chat_message_audio_record_progress.xml new file mode 100644 index 000000000..c004038dc --- /dev/null +++ b/app/src/main/res/drawable/chat_message_audio_record_progress.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/chat_message_voice_recording_background.xml b/app/src/main/res/drawable/chat_message_voice_recording_background.xml new file mode 100644 index 000000000..3725abfdf --- /dev/null +++ b/app/src/main/res/drawable/chat_message_voice_recording_background.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/record_audio_message.xml b/app/src/main/res/drawable/record_audio_message.xml new file mode 100644 index 000000000..a3b70050b --- /dev/null +++ b/app/src/main/res/drawable/record_audio_message.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/record_pause_dark.xml b/app/src/main/res/drawable/record_pause_dark.xml new file mode 100644 index 000000000..7e7a59878 --- /dev/null +++ b/app/src/main/res/drawable/record_pause_dark.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/app/src/main/res/drawable/record_pause_light.xml b/app/src/main/res/drawable/record_pause_light.xml new file mode 100644 index 000000000..5af5e7552 --- /dev/null +++ b/app/src/main/res/drawable/record_pause_light.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/app/src/main/res/drawable/record_play_dark.xml b/app/src/main/res/drawable/record_play_dark.xml new file mode 100644 index 000000000..33721dc80 --- /dev/null +++ b/app/src/main/res/drawable/record_play_dark.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/app/src/main/res/drawable/record_play_light.xml b/app/src/main/res/drawable/record_play_light.xml new file mode 100644 index 000000000..f17480c37 --- /dev/null +++ b/app/src/main/res/drawable/record_play_light.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/app/src/main/res/drawable/record_stop_dark.xml b/app/src/main/res/drawable/record_stop_dark.xml new file mode 100644 index 000000000..c6d3ef924 --- /dev/null +++ b/app/src/main/res/drawable/record_stop_dark.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/app/src/main/res/drawable/record_stop_light.xml b/app/src/main/res/drawable/record_stop_light.xml new file mode 100644 index 000000000..1d80da938 --- /dev/null +++ b/app/src/main/res/drawable/record_stop_light.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/app/src/main/res/drawable/recording_play_pause.xml b/app/src/main/res/drawable/recording_play_pause.xml index 4de1934d3..cb4e8130a 100644 --- a/app/src/main/res/drawable/recording_play_pause.xml +++ b/app/src/main/res/drawable/recording_play_pause.xml @@ -1,11 +1,11 @@ - - diff --git a/app/src/main/res/drawable/round_recording_button_background_dark.xml b/app/src/main/res/drawable/round_recording_button_background_dark.xml new file mode 100644 index 000000000..70fdb4900 --- /dev/null +++ b/app/src/main/res/drawable/round_recording_button_background_dark.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/round_recording_button_background_light.xml b/app/src/main/res/drawable/round_recording_button_background_light.xml new file mode 100644 index 000000000..d6194f718 --- /dev/null +++ b/app/src/main/res/drawable/round_recording_button_background_light.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/layout/chat_message_attachment_cell.xml b/app/src/main/res/layout/chat_message_attachment_cell.xml index e5c0d31de..46a1a90db 100644 --- a/app/src/main/res/layout/chat_message_attachment_cell.xml +++ b/app/src/main/res/layout/chat_message_attachment_cell.xml @@ -39,9 +39,11 @@ diff --git a/app/src/main/res/layout/chat_message_content_cell.xml b/app/src/main/res/layout/chat_message_content_cell.xml index ab447e257..d723906ca 100644 --- a/app/src/main/res/layout/chat_message_content_cell.xml +++ b/app/src/main/res/layout/chat_message_content_cell.xml @@ -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 && 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 && data.video ? View.VISIBLE : View.GONE}" android:scaleType="@{data.alone ? ScaleType.FIT_CENTER : ScaleType.CENTER_CROP}" android:adjustViewBounds="true" /> + + + android:visibility="@{data.downloadable || data.audio || data.pdf || data.genericFile ? View.VISIBLE : View.GONE}"> @@ -89,7 +96,7 @@ + android:layout_height="wrap_content"> @@ -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 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/chat_message_reply_content_cell.xml b/app/src/main/res/layout/chat_message_reply_content_cell.xml index 9da6290dd..5e4acae7a 100644 --- a/app/src/main/res/layout/chat_message_reply_content_cell.xml +++ b/app/src/main/res/layout/chat_message_reply_content_cell.xml @@ -39,12 +39,24 @@ + + + android:layout_height="wrap_content"> + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/chat_message_voice_recording.xml b/app/src/main/res/layout/chat_message_voice_recording.xml new file mode 100644 index 000000000..31444841d --- /dev/null +++ b/app/src/main/res/layout/chat_message_voice_recording.xml @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/chat_room_detail_fragment.xml b/app/src/main/res/layout/chat_room_detail_fragment.xml index 12db0e4be..f13d6ed1f 100644 --- a/app/src/main/res/layout/chat_room_detail_fragment.xml +++ b/app/src/main/res/layout/chat_room_detail_fragment.xml @@ -24,11 +24,8 @@ name="attachFileClickListener" type="android.view.View.OnClickListener"/> - + name="voiceRecordingTouchListener" + type="android.view.View.OnTouchListener" /> @@ -96,7 +93,7 @@ + + + + @@ -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" /> + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/dimen.xml b/app/src/main/res/values/dimen.xml index 3f8ea7826..05457d1d4 100644 --- a/app/src/main/res/values/dimen.xml +++ b/app/src/main/res/values/dimen.xml @@ -28,4 +28,6 @@ 250dp 50dp 6.7dp + 30dp + 40dp \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b4aa86075..9dc80758b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -218,6 +218,7 @@ Open as text Reply Message + Hold button to record voice message No recordings @@ -443,6 +444,7 @@ Always open files inside this app You\'ll still be able to export them in third-party apps Enable ephemeral messages (beta) + Auto download incoming voice recordings Use WiFi only @@ -643,6 +645,7 @@ Attach a file to the message File attached to the message Send message + Record audio message Show or hide the participant devices Ephemeral duration selected Change ephemeral duration by selected value @@ -715,4 +718,10 @@ Open file in third-party app Cancel message forward Cancel sharing + Cancel reply + Record a voice message + Stop voice recording + Cancel voice recording + Pause voice recording + Play voice recording diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index ab7875f81..68a0c9e5c 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -114,13 +114,13 @@ diff --git a/build.gradle b/build.gradle index 4f1406228..a49aca70b 100644 --- a/build.gradle +++ b/build.gradle @@ -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"