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)
}
/* 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

View file

@ -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<String>()
val voiceRecording = MutableLiveData<Boolean>()
val voiceRecordingInProgress = MutableLiveData<Boolean>()
val formattedVoiceRecordingDuration = MutableLiveData<String>()
val isPlayingVoiceRecord = MutableLiveData<Boolean>()
val requestKeyboardHidingEvent: MutableLiveData<Event<Boolean>> by lazy {
MutableLiveData<Event<Boolean>>()
}
@ -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<FileModel>()
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)
}
}
}

View file

@ -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")
}
}
}

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: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" />
<androidx.constraintlayout.widget.Group
android:id="@+id/standard_messages"
android:layout_width="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" />
<include
@ -181,6 +181,22 @@
android:padding="8dp"
android:src="@drawable/stop_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_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_constraintStart_toStartOf="@id/voice_record_progress"
app:layout_constraintTop_toTopOf="@id/voice_record_progress"
@ -188,7 +204,7 @@
<androidx.appcompat.widget.AppCompatTextView
style="@style/default_text_style"
android:id="@+id/voice_recording_length"
android:id="@+id/voice_recording_duration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="10dp"
@ -196,10 +212,33 @@
android:paddingEnd="12dp"
android:paddingTop="5dp"
android:paddingBottom="5dp"
android:text="00:00"
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}"
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_constraintEnd_toEndOf="@id/voice_record_progress"
app:layout_constraintTop_toTopOf="@id/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"