From 80fe93c6c46e8bce3aee268aa77552cce09eb661 Mon Sep 17 00:00:00 2001 From: Sylvain Berfini Date: Fri, 10 Nov 2023 13:52:16 +0100 Subject: [PATCH] Added voice recording, have to do voice record player --- .../java/org/linphone/core/CorePreferences.kt | 6 + .../SendMessageInConversationViewModel.kt | 211 ++++++++++++++++-- .../org/linphone/utils/AudioRouteUtils.kt | 74 ++++++ app/src/main/res/drawable/pause_fill.xml | 9 + ...at_conversation_send_area_bottom_sheet.xml | 53 ++++- 5 files changed, 322 insertions(+), 31 deletions(-) create mode 100644 app/src/main/res/drawable/pause_fill.xml diff --git a/app/src/main/java/org/linphone/core/CorePreferences.kt b/app/src/main/java/org/linphone/core/CorePreferences.kt index 73efdf879..fa40d4a21 100644 --- a/app/src/main/java/org/linphone/core/CorePreferences.kt +++ b/app/src/main/java/org/linphone/core/CorePreferences.kt @@ -93,6 +93,12 @@ class CorePreferences @UiThread constructor(private val context: Context) { config.setBool("app", "auto_start_call_record", 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) + /** -1 means auto, 0 no, 1 yes */ @get:WorkerThread @set:WorkerThread var darkMode: Int diff --git a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/SendMessageInConversationViewModel.kt b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/SendMessageInConversationViewModel.kt index 224cc14fc..e73333ba0 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/SendMessageInConversationViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/SendMessageInConversationViewModel.kt @@ -24,17 +24,27 @@ import androidx.annotation.WorkerThread import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.media.AudioFocusRequestCompat +import java.text.SimpleDateFormat +import java.util.Locale +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.linphone.LinphoneApplication.Companion.coreContext +import org.linphone.LinphoneApplication.Companion.corePreferences import org.linphone.core.ChatMessage import org.linphone.core.ChatRoom import org.linphone.core.ChatRoomListenerStub import org.linphone.core.EventLog import org.linphone.core.Factory +import org.linphone.core.Recorder import org.linphone.core.tools.Log import org.linphone.ui.main.chat.model.ChatMessageModel import org.linphone.ui.main.chat.model.FileModel import org.linphone.ui.main.chat.model.ParticipantModel +import org.linphone.utils.AudioRouteUtils import org.linphone.utils.Event import org.linphone.utils.FileUtils import org.linphone.utils.LinphoneUtils @@ -62,8 +72,14 @@ class SendMessageInConversationViewModel @UiThread constructor() : ViewModel() { val isReplyingToMessage = MutableLiveData() + val voiceRecording = MutableLiveData() + val voiceRecordingInProgress = MutableLiveData() + val formattedVoiceRecordingDuration = MutableLiveData() + + val isPlayingVoiceRecord = MutableLiveData() + val requestKeyboardHidingEvent: MutableLiveData> by lazy { MutableLiveData>() } @@ -80,6 +96,10 @@ class SendMessageInConversationViewModel @UiThread constructor() : ViewModel() { private var chatMessageToReplyTo: ChatMessage? = null + private lateinit var voiceMessageRecorder: Recorder + + private var voiceRecordAudioFocusRequest: AudioFocusRequestCompat? = null + private val chatRoomListener = object : ChatRoomListenerStub() { @WorkerThread override fun onParticipantAdded(chatRoom: ChatRoom, eventLog: EventLog) { @@ -94,6 +114,7 @@ class SendMessageInConversationViewModel @UiThread constructor() : ViewModel() { init { isEmojiPickerOpen.value = false + isPlayingVoiceRecord.value = false } override fun onCleared() { @@ -105,6 +126,12 @@ class SendMessageInConversationViewModel @UiThread constructor() : ViewModel() { } } + if (::voiceMessageRecorder.isInitialized) { + if (voiceMessageRecorder.state != Recorder.State.Closed) { + voiceMessageRecorder.close() + } + } + coreContext.postOnCoreThread { if (::chatRoom.isInitialized) { chatRoom.removeListener(chatRoomListener) @@ -169,26 +196,40 @@ class SendMessageInConversationViewModel @UiThread constructor() : ViewModel() { message.addUtf8TextContent(toSend) } - for (attachment in attachments.value.orEmpty()) { - val content = Factory.instance().createContent() - - content.type = when (attachment.mimeType) { - FileUtils.MimeType.Image -> "image" - FileUtils.MimeType.Audio -> "audio" - FileUtils.MimeType.Video -> "video" - FileUtils.MimeType.Pdf -> "application" - FileUtils.MimeType.PlainText -> "text" - else -> "file" - } - content.subtype = if (attachment.mimeType == FileUtils.MimeType.PlainText) { - "plain" + if (voiceRecording.value == true && voiceMessageRecorder.file != null) { + stopVoiceRecorder() + val content = voiceMessageRecorder.createContent() + if (content != null) { + Log.i( + "$TAG Voice recording content created, file name is ${content.name} and duration is ${content.fileDuration}" + ) + message.addContent(content) } else { - FileUtils.getExtensionFromFileName(attachment.fileName) + Log.e("$TAG Voice recording content couldn't be created!") } - content.name = attachment.fileName - content.filePath = attachment.file // Let the file body handler take care of the upload + } else { + for (attachment in attachments.value.orEmpty()) { + val content = Factory.instance().createContent() - message.addFileContent(content) + content.type = when (attachment.mimeType) { + FileUtils.MimeType.Image -> "image" + FileUtils.MimeType.Audio -> "audio" + FileUtils.MimeType.Video -> "video" + FileUtils.MimeType.Pdf -> "application" + FileUtils.MimeType.PlainText -> "text" + else -> "file" + } + content.subtype = if (attachment.mimeType == FileUtils.MimeType.PlainText) { + "plain" + } else { + FileUtils.getExtensionFromFileName(attachment.fileName) + } + content.name = attachment.fileName + // Let the file body handler take care of the upload + content.filePath = attachment.file + + message.addFileContent(content) + } } if (message.contents.isNotEmpty()) { @@ -203,6 +244,9 @@ class SendMessageInConversationViewModel @UiThread constructor() : ViewModel() { isParticipantsListOpen.postValue(false) isEmojiPickerOpen.postValue(false) + stopVoiceRecorder() + voiceRecording.postValue(false) + // Warning: do not delete files val attachmentsList = arrayListOf() attachments.postValue(attachmentsList) @@ -270,24 +314,43 @@ class SendMessageInConversationViewModel @UiThread constructor() : ViewModel() { @UiThread fun startVoiceMessageRecording() { - voiceRecordingInProgress.value = true + // TODO: check microphone permission + coreContext.postOnCoreThread { + voiceRecording.postValue(true) + initVoiceRecorder() + + voiceRecordingInProgress.postValue(true) + startVoiceRecorder() + } } @UiThread fun stopVoiceMessageRecording() { + coreContext.postOnCoreThread { + stopVoiceRecorder() + } } @UiThread fun cancelVoiceMessageRecording() { - voiceRecordingInProgress.value = false + coreContext.postOnCoreThread { + stopVoiceRecorder() + + val path = voiceMessageRecorder.file + if (path != null) { + viewModelScope.launch { + Log.i("$TAG Deleting voice recording file: $path") + FileUtils.deleteFile(path) + } + } + + voiceRecording.postValue(false) + } } @UiThread - fun playVoiceMessageRecording() { - } - - @UiThread - fun pauseVoiceMessageRecording() { + fun togglePlayPauseVoiceRecord() { + isPlayingVoiceRecord.value = isPlayingVoiceRecord.value == false } @WorkerThread @@ -309,4 +372,104 @@ class SendMessageInConversationViewModel @UiThread constructor() : ViewModel() { participants.postValue(participantsList) } + + @WorkerThread + private fun initVoiceRecorder() { + val core = coreContext.core + Log.i("$TAG Creating voice message recorder") + val recorderParams = core.createRecorderParams() + recorderParams.fileFormat = Recorder.FileFormat.Mkv + + val recordingAudioDevice = AudioRouteUtils.getAudioRecordingDeviceIdForVoiceMessage() + recorderParams.audioDevice = recordingAudioDevice + Log.i( + "$TAG Using device ${recorderParams.audioDevice?.id} to make the voice message recording" + ) + + voiceMessageRecorder = core.createRecorder(recorderParams) + Log.i("$TAG Voice message recorder created") + } + + @WorkerThread + private fun startVoiceRecorder() { + if (voiceRecordAudioFocusRequest == null) { + Log.i("$TAG Requesting audio focus for voice message recording") + voiceRecordAudioFocusRequest = AudioRouteUtils.acquireAudioFocusForVoiceRecordingOrPlayback( + coreContext.context + ) + } + + when (voiceMessageRecorder.state) { + Recorder.State.Running -> Log.w("$TAG Recorder is already recording") + Recorder.State.Paused -> { + Log.w("$TAG Recorder is paused, resuming recording") + voiceMessageRecorder.start() + } + Recorder.State.Closed -> { + val extension = when (voiceMessageRecorder.params.fileFormat) { + Recorder.FileFormat.Mkv -> "mkv" + else -> "wav" + } + val tempFileName = "voice-recording-${System.currentTimeMillis()}.$extension" + val file = FileUtils.getFileStoragePath(tempFileName) + Log.w( + "$TAG Recorder is closed, starting recording in ${file.absoluteFile}" + ) + voiceMessageRecorder.open(file.absolutePath) + voiceMessageRecorder.start() + } + else -> {} + } + + val duration = voiceMessageRecorder.duration + val formattedDuration = SimpleDateFormat("mm:ss", Locale.getDefault()).format(duration) // duration is in ms + formattedVoiceRecordingDuration.postValue(formattedDuration) + + val maxVoiceRecordDuration = corePreferences.voiceRecordingMaxDuration + tickerFlowRecording().onEach { + coreContext.postOnCoreThread { + val duration = voiceMessageRecorder.duration + val formattedDuration = SimpleDateFormat("mm:ss", Locale.getDefault()).format( + duration + ) // duration is in ms + formattedVoiceRecordingDuration.postValue(formattedDuration) + + if (duration >= maxVoiceRecordDuration) { + Log.w( + "$TAG Max duration for voice recording exceeded (${maxVoiceRecordDuration}ms), stopping." + ) + stopVoiceRecorder() + // TOOD: show toast + } + } + }.launchIn(viewModelScope) + } + + @WorkerThread + private fun stopVoiceRecorder() { + if (voiceMessageRecorder.state == Recorder.State.Running) { + Log.i("$TAG Closing voice recorder") + voiceMessageRecorder.pause() + voiceMessageRecorder.close() + } + + val request = voiceRecordAudioFocusRequest + if (request != null) { + Log.i("$TAG Releasing voice recording audio focus request") + AudioRouteUtils.releaseAudioFocusForVoiceRecordingOrPlayback( + coreContext.context, + request + ) + voiceRecordAudioFocusRequest = null + } + + voiceRecordingInProgress.postValue(false) + } + + private fun tickerFlowRecording() = flow { + while (voiceRecordingInProgress.value == true) { + emit(Unit) + delay(500) + } + } } diff --git a/app/src/main/java/org/linphone/utils/AudioRouteUtils.kt b/app/src/main/java/org/linphone/utils/AudioRouteUtils.kt index 4a6a5526e..cc3dc5880 100644 --- a/app/src/main/java/org/linphone/utils/AudioRouteUtils.kt +++ b/app/src/main/java/org/linphone/utils/AudioRouteUtils.kt @@ -19,7 +19,13 @@ */ package org.linphone.utils +import android.content.Context +import android.media.AudioManager +import androidx.annotation.AnyThread import androidx.annotation.WorkerThread +import androidx.media.AudioAttributesCompat +import androidx.media.AudioFocusRequestCompat +import androidx.media.AudioManagerCompat import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.core.AudioDevice import org.linphone.core.Call @@ -193,5 +199,73 @@ class AudioRouteUtils { ) return headphonesCard ?: bluetoothCard ?: speakerCard ?: earpieceCard } + + @WorkerThread + fun getAudioRecordingDeviceIdForVoiceMessage(): AudioDevice? { + // In case no headset/hearing aid/bluetooth is connected, use microphone sound card + // If none are available, default one will be used + var headsetCard: AudioDevice? = null + var bluetoothCard: AudioDevice? = null + var microphoneCard: AudioDevice? = null + for (device in coreContext.core.audioDevices) { + if (device.hasCapability(AudioDevice.Capabilities.CapabilityRecord)) { + when (device.type) { + AudioDevice.Type.Headphones, AudioDevice.Type.Headset -> { + headsetCard = device + } + AudioDevice.Type.Bluetooth, AudioDevice.Type.HearingAid -> { + bluetoothCard = device + } + AudioDevice.Type.Microphone -> { + microphoneCard = device + } + else -> {} + } + } + } + Log.i( + "$TAG Found headset/headphones/hearingAid sound card [$headsetCard], bluetooth sound card [$bluetoothCard] and microphone card [$microphoneCard]" + ) + return headsetCard ?: bluetoothCard ?: microphoneCard + } + + @AnyThread + fun acquireAudioFocusForVoiceRecordingOrPlayback(context: Context): AudioFocusRequestCompat { + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + val audioAttrs = AudioAttributesCompat.Builder() + .setUsage(AudioAttributesCompat.USAGE_MEDIA) + .setContentType(AudioAttributesCompat.CONTENT_TYPE_SPEECH) + .build() + + val request = + AudioFocusRequestCompat.Builder( + AudioManagerCompat.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE + ) + .setAudioAttributes(audioAttrs) + .setOnAudioFocusChangeListener { } + .build() + when (AudioManagerCompat.requestAudioFocus(audioManager, request)) { + AudioManager.AUDIOFOCUS_REQUEST_GRANTED -> { + Log.i("$TAG Voice recording/playback audio focus request granted") + } + AudioManager.AUDIOFOCUS_REQUEST_FAILED -> { + Log.w("$TAG Voice recording/playback audio focus request failed") + } + AudioManager.AUDIOFOCUS_REQUEST_DELAYED -> { + Log.w("$TAG Voice recording/playback audio focus request delayed") + } + } + return request + } + + @AnyThread + fun releaseAudioFocusForVoiceRecordingOrPlayback( + context: Context, + request: AudioFocusRequestCompat + ) { + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + AudioManagerCompat.abandonAudioFocusRequest(audioManager, request) + Log.i("$TAG Voice recording/playback audio focus request abandoned") + } } } diff --git a/app/src/main/res/drawable/pause_fill.xml b/app/src/main/res/drawable/pause_fill.xml new file mode 100644 index 000000000..7b9e92bfb --- /dev/null +++ b/app/src/main/res/drawable/pause_fill.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/chat_conversation_send_area_bottom_sheet.xml b/app/src/main/res/layout/chat_conversation_send_area_bottom_sheet.xml index 5104ce518..3c2047555 100644 --- a/app/src/main/res/layout/chat_conversation_send_area_bottom_sheet.xml +++ b/app/src/main/res/layout/chat_conversation_send_area_bottom_sheet.xml @@ -24,14 +24,14 @@ android:id="@+id/voice_recording" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:visibility="@{viewModel.voiceRecordingInProgress ? View.VISIBLE : View.GONE, default=gone}" - app:constraint_referenced_ids="cancel_voice_message, voice_record_progress, stop_recording, voice_recording_length" /> + android:visibility="@{viewModel.voiceRecording ? View.VISIBLE : View.GONE, default=gone}" + app:constraint_referenced_ids="cancel_voice_message, voice_record_progress" /> + + + + @@ -209,7 +248,7 @@ android:layout_width="40dp" android:layout_height="0dp" android:layout_marginEnd="4dp" - android:visibility="@{viewModel.textToSend.length() > 0 || viewModel.attachments.size() > 0 || viewModel.voiceRecordingInProgress ? View.VISIBLE : View.GONE, default=gone}" + android:visibility="@{viewModel.textToSend.length() > 0 || viewModel.attachments.size() > 0 || viewModel.voiceRecording ? View.VISIBLE : View.GONE, default=gone}" android:onClick="@{() -> viewModel.sendMessage()}" android:padding="8dp" android:src="@drawable/paper_plane_tilt" @@ -223,7 +262,7 @@ android:layout_width="40dp" android:layout_height="0dp" android:layout_marginEnd="4dp" - android:visibility="@{viewModel.textToSend.length() > 0 || viewModel.attachments.size() > 0 || viewModel.voiceRecordingInProgress ? View.GONE : View.VISIBLE}" + android:visibility="@{viewModel.textToSend.length() > 0 || viewModel.attachments.size() > 0 || viewModel.voiceRecording ? View.GONE : View.VISIBLE}" android:onClick="@{() -> viewModel.startVoiceMessageRecording()}" android:padding="8dp" android:src="@drawable/microphone"