Added voice recording, have to do voice record player

This commit is contained in:
Sylvain Berfini 2023-11-10 13:52:16 +01:00
parent 3071c079ba
commit 80fe93c6c4
5 changed files with 322 additions and 31 deletions

View file

@ -93,6 +93,12 @@ class CorePreferences @UiThread constructor(private val context: Context) {
config.setBool("app", "auto_start_call_record", value) 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 */ /** -1 means auto, 0 no, 1 yes */
@get:WorkerThread @set:WorkerThread @get:WorkerThread @set:WorkerThread
var darkMode: Int var darkMode: Int

View file

@ -24,17 +24,27 @@ import androidx.annotation.WorkerThread
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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 kotlinx.coroutines.launch
import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.core.ChatMessage import org.linphone.core.ChatMessage
import org.linphone.core.ChatRoom import org.linphone.core.ChatRoom
import org.linphone.core.ChatRoomListenerStub import org.linphone.core.ChatRoomListenerStub
import org.linphone.core.EventLog import org.linphone.core.EventLog
import org.linphone.core.Factory import org.linphone.core.Factory
import org.linphone.core.Recorder
import org.linphone.core.tools.Log import org.linphone.core.tools.Log
import org.linphone.ui.main.chat.model.ChatMessageModel import org.linphone.ui.main.chat.model.ChatMessageModel
import org.linphone.ui.main.chat.model.FileModel import org.linphone.ui.main.chat.model.FileModel
import org.linphone.ui.main.chat.model.ParticipantModel import org.linphone.ui.main.chat.model.ParticipantModel
import org.linphone.utils.AudioRouteUtils
import org.linphone.utils.Event import org.linphone.utils.Event
import org.linphone.utils.FileUtils import org.linphone.utils.FileUtils
import org.linphone.utils.LinphoneUtils import org.linphone.utils.LinphoneUtils
@ -62,8 +72,14 @@ class SendMessageInConversationViewModel @UiThread constructor() : ViewModel() {
val isReplyingToMessage = MutableLiveData<String>() val isReplyingToMessage = MutableLiveData<String>()
val voiceRecording = MutableLiveData<Boolean>()
val voiceRecordingInProgress = MutableLiveData<Boolean>() val voiceRecordingInProgress = MutableLiveData<Boolean>()
val formattedVoiceRecordingDuration = MutableLiveData<String>()
val isPlayingVoiceRecord = MutableLiveData<Boolean>()
val requestKeyboardHidingEvent: MutableLiveData<Event<Boolean>> by lazy { val requestKeyboardHidingEvent: MutableLiveData<Event<Boolean>> by lazy {
MutableLiveData<Event<Boolean>>() MutableLiveData<Event<Boolean>>()
} }
@ -80,6 +96,10 @@ class SendMessageInConversationViewModel @UiThread constructor() : ViewModel() {
private var chatMessageToReplyTo: ChatMessage? = null private var chatMessageToReplyTo: ChatMessage? = null
private lateinit var voiceMessageRecorder: Recorder
private var voiceRecordAudioFocusRequest: AudioFocusRequestCompat? = null
private val chatRoomListener = object : ChatRoomListenerStub() { private val chatRoomListener = object : ChatRoomListenerStub() {
@WorkerThread @WorkerThread
override fun onParticipantAdded(chatRoom: ChatRoom, eventLog: EventLog) { override fun onParticipantAdded(chatRoom: ChatRoom, eventLog: EventLog) {
@ -94,6 +114,7 @@ class SendMessageInConversationViewModel @UiThread constructor() : ViewModel() {
init { init {
isEmojiPickerOpen.value = false isEmojiPickerOpen.value = false
isPlayingVoiceRecord.value = false
} }
override fun onCleared() { 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 { coreContext.postOnCoreThread {
if (::chatRoom.isInitialized) { if (::chatRoom.isInitialized) {
chatRoom.removeListener(chatRoomListener) chatRoom.removeListener(chatRoomListener)
@ -169,26 +196,40 @@ class SendMessageInConversationViewModel @UiThread constructor() : ViewModel() {
message.addUtf8TextContent(toSend) message.addUtf8TextContent(toSend)
} }
for (attachment in attachments.value.orEmpty()) { if (voiceRecording.value == true && voiceMessageRecorder.file != null) {
val content = Factory.instance().createContent() stopVoiceRecorder()
val content = voiceMessageRecorder.createContent()
content.type = when (attachment.mimeType) { if (content != null) {
FileUtils.MimeType.Image -> "image" Log.i(
FileUtils.MimeType.Audio -> "audio" "$TAG Voice recording content created, file name is ${content.name} and duration is ${content.fileDuration}"
FileUtils.MimeType.Video -> "video" )
FileUtils.MimeType.Pdf -> "application" message.addContent(content)
FileUtils.MimeType.PlainText -> "text"
else -> "file"
}
content.subtype = if (attachment.mimeType == FileUtils.MimeType.PlainText) {
"plain"
} else { } else {
FileUtils.getExtensionFromFileName(attachment.fileName) Log.e("$TAG Voice recording content couldn't be created!")
} }
content.name = attachment.fileName } else {
content.filePath = attachment.file // Let the file body handler take care of the upload 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()) { if (message.contents.isNotEmpty()) {
@ -203,6 +244,9 @@ class SendMessageInConversationViewModel @UiThread constructor() : ViewModel() {
isParticipantsListOpen.postValue(false) isParticipantsListOpen.postValue(false)
isEmojiPickerOpen.postValue(false) isEmojiPickerOpen.postValue(false)
stopVoiceRecorder()
voiceRecording.postValue(false)
// Warning: do not delete files // Warning: do not delete files
val attachmentsList = arrayListOf<FileModel>() val attachmentsList = arrayListOf<FileModel>()
attachments.postValue(attachmentsList) attachments.postValue(attachmentsList)
@ -270,24 +314,43 @@ class SendMessageInConversationViewModel @UiThread constructor() : ViewModel() {
@UiThread @UiThread
fun startVoiceMessageRecording() { fun startVoiceMessageRecording() {
voiceRecordingInProgress.value = true // TODO: check microphone permission
coreContext.postOnCoreThread {
voiceRecording.postValue(true)
initVoiceRecorder()
voiceRecordingInProgress.postValue(true)
startVoiceRecorder()
}
} }
@UiThread @UiThread
fun stopVoiceMessageRecording() { fun stopVoiceMessageRecording() {
coreContext.postOnCoreThread {
stopVoiceRecorder()
}
} }
@UiThread @UiThread
fun cancelVoiceMessageRecording() { 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 @UiThread
fun playVoiceMessageRecording() { fun togglePlayPauseVoiceRecord() {
} isPlayingVoiceRecord.value = isPlayingVoiceRecord.value == false
@UiThread
fun pauseVoiceMessageRecording() {
} }
@WorkerThread @WorkerThread
@ -309,4 +372,104 @@ class SendMessageInConversationViewModel @UiThread constructor() : ViewModel() {
participants.postValue(participantsList) 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)
}
}
} }

View file

@ -19,7 +19,13 @@
*/ */
package org.linphone.utils package org.linphone.utils
import android.content.Context
import android.media.AudioManager
import androidx.annotation.AnyThread
import androidx.annotation.WorkerThread 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.LinphoneApplication.Companion.coreContext
import org.linphone.core.AudioDevice import org.linphone.core.AudioDevice
import org.linphone.core.Call import org.linphone.core.Call
@ -193,5 +199,73 @@ class AudioRouteUtils {
) )
return headphonesCard ?: bluetoothCard ?: speakerCard ?: earpieceCard 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")
}
} }
} }

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="256"
android:viewportHeight="256">
<path
android:pathData="M216,48V208a16,16 0,0 1,-16 16H160a16,16 0,0 1,-16 -16V48a16,16 0,0 1,16 -16h40A16,16 0,0 1,216 48ZM96,32H56A16,16 0,0 0,40 48V208a16,16 0,0 0,16 16H96a16,16 0,0 0,16 -16V48A16,16 0,0 0,96 32Z"
android:fillColor="#4e6074"/>
</vector>

View file

@ -24,14 +24,14 @@
android:id="@+id/voice_recording" android:id="@+id/voice_recording"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:visibility="@{viewModel.voiceRecordingInProgress ? View.VISIBLE : View.GONE, default=gone}" android:visibility="@{viewModel.voiceRecording ? View.VISIBLE : View.GONE, default=gone}"
app:constraint_referenced_ids="cancel_voice_message, voice_record_progress, stop_recording, voice_recording_length" /> app:constraint_referenced_ids="cancel_voice_message, voice_record_progress" />
<androidx.constraintlayout.widget.Group <androidx.constraintlayout.widget.Group
android:id="@+id/standard_messages" android:id="@+id/standard_messages"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:visibility="@{viewModel.voiceRecordingInProgress ? View.INVISIBLE : View.VISIBLE}" android:visibility="@{viewModel.voiceRecording ? View.INVISIBLE : View.VISIBLE}"
app:constraint_referenced_ids="emoji_picker_toggle, attach_file, message_area_background, message_to_send" /> app:constraint_referenced_ids="emoji_picker_toggle, attach_file, message_area_background, message_to_send" />
<include <include
@ -181,6 +181,22 @@
android:padding="8dp" android:padding="8dp"
android:src="@drawable/stop_fill" android:src="@drawable/stop_fill"
android:background="@drawable/circle_white_button_background" android:background="@drawable/circle_white_button_background"
android:visibility="@{viewModel.voiceRecording &amp;&amp; viewModel.voiceRecordingInProgress ? View.VISIBLE : View.GONE, default=gone}"
app:layout_constraintBottom_toBottomOf="@id/voice_record_progress"
app:layout_constraintStart_toStartOf="@id/voice_record_progress"
app:layout_constraintTop_toTopOf="@id/voice_record_progress"
app:tint="@color/orange_main_500" />
<ImageView
android:id="@+id/play_pause_voice_record"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="10dp"
android:onClick="@{() -> viewModel.togglePlayPauseVoiceRecord()}"
android:padding="8dp"
android:src="@{viewModel.isPlayingVoiceRecord ? @drawable/pause_fill : @drawable/play_fill, default=@drawable/play_fill}"
android:background="@drawable/circle_white_button_background"
android:visibility="@{viewModel.voiceRecording &amp;&amp; !viewModel.voiceRecordingInProgress ? View.VISIBLE : View.GONE, default=gone}"
app:layout_constraintBottom_toBottomOf="@id/voice_record_progress" app:layout_constraintBottom_toBottomOf="@id/voice_record_progress"
app:layout_constraintStart_toStartOf="@id/voice_record_progress" app:layout_constraintStart_toStartOf="@id/voice_record_progress"
app:layout_constraintTop_toTopOf="@id/voice_record_progress" app:layout_constraintTop_toTopOf="@id/voice_record_progress"
@ -188,7 +204,7 @@
<androidx.appcompat.widget.AppCompatTextView <androidx.appcompat.widget.AppCompatTextView
style="@style/default_text_style" style="@style/default_text_style"
android:id="@+id/voice_recording_length" android:id="@+id/voice_recording_duration"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="10dp" android:layout_marginEnd="10dp"
@ -196,10 +212,33 @@
android:paddingEnd="12dp" android:paddingEnd="12dp"
android:paddingTop="5dp" android:paddingTop="5dp"
android:paddingBottom="5dp" android:paddingBottom="5dp"
android:text="00:00" android:text="@{viewModel.formattedVoiceRecordingDuration, default=`00:00`}"
android:textSize="14sp" android:textSize="14sp"
android:textColor="@color/gray_main2_600" android:textColor="@color/gray_main2_600"
android:background="@drawable/shape_squircle_white_r50_background" android:background="@drawable/shape_squircle_white_r50_background"
android:visibility="@{viewModel.voiceRecording &amp;&amp; viewModel.voiceRecordingInProgress ? View.VISIBLE : View.GONE, default=gone}"
android:drawableStart="@drawable/record_fill"
android:drawablePadding="8dp"
app:drawableTint="@color/red_danger_500"
app:layout_constraintBottom_toBottomOf="@id/voice_record_progress"
app:layout_constraintEnd_toEndOf="@id/voice_record_progress"
app:layout_constraintTop_toTopOf="@id/voice_record_progress"/>
<androidx.appcompat.widget.AppCompatTextView
style="@style/default_text_style"
android:id="@+id/voice_record_duration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="10dp"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingTop="5dp"
android:paddingBottom="5dp"
android:text="@{viewModel.formattedVoiceRecordingDuration, default=`00:00`}"
android:textSize="14sp"
android:textColor="@color/gray_main2_600"
android:background="@drawable/shape_squircle_white_r50_background"
android:visibility="@{viewModel.voiceRecording &amp;&amp; !viewModel.voiceRecordingInProgress ? View.VISIBLE : View.GONE, default=gone}"
app:layout_constraintBottom_toBottomOf="@id/voice_record_progress" app:layout_constraintBottom_toBottomOf="@id/voice_record_progress"
app:layout_constraintEnd_toEndOf="@id/voice_record_progress" app:layout_constraintEnd_toEndOf="@id/voice_record_progress"
app:layout_constraintTop_toTopOf="@id/voice_record_progress"/> app:layout_constraintTop_toTopOf="@id/voice_record_progress"/>
@ -209,7 +248,7 @@
android:layout_width="40dp" android:layout_width="40dp"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_marginEnd="4dp" 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:onClick="@{() -> viewModel.sendMessage()}"
android:padding="8dp" android:padding="8dp"
android:src="@drawable/paper_plane_tilt" android:src="@drawable/paper_plane_tilt"
@ -223,7 +262,7 @@
android:layout_width="40dp" android:layout_width="40dp"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_marginEnd="4dp" 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:onClick="@{() -> viewModel.startVoiceMessageRecording()}"
android:padding="8dp" android:padding="8dp"
android:src="@drawable/microphone" android:src="@drawable/microphone"