Record and send voice messages (#1596)
--------- Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
parent
da345f9afa
commit
5c582bba1b
68 changed files with 2274 additions and 82 deletions
1
changelog.d/1596.feature
Normal file
1
changelog.d/1596.feature
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Record and send voice messages
|
||||||
|
|
@ -51,6 +51,7 @@ dependencies {
|
||||||
implementation(projects.libraries.mediaupload.api)
|
implementation(projects.libraries.mediaupload.api)
|
||||||
implementation(projects.libraries.permissions.api)
|
implementation(projects.libraries.permissions.api)
|
||||||
implementation(projects.libraries.preferences.api)
|
implementation(projects.libraries.preferences.api)
|
||||||
|
implementation(projects.libraries.voicerecorder.api)
|
||||||
implementation(projects.features.networkmonitor.api)
|
implementation(projects.features.networkmonitor.api)
|
||||||
implementation(projects.services.analytics.api)
|
implementation(projects.services.analytics.api)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
|
|
@ -80,6 +81,7 @@ dependencies {
|
||||||
testImplementation(projects.libraries.permissions.test)
|
testImplementation(projects.libraries.permissions.test)
|
||||||
testImplementation(projects.libraries.preferences.test)
|
testImplementation(projects.libraries.preferences.test)
|
||||||
testImplementation(projects.libraries.textcomposer.test)
|
testImplementation(projects.libraries.textcomposer.test)
|
||||||
|
testImplementation(projects.libraries.voicerecorder.test)
|
||||||
testImplementation(libs.test.mockk)
|
testImplementation(libs.test.mockk)
|
||||||
|
|
||||||
ksp(libs.showkase.processor)
|
ksp(libs.showkase.processor)
|
||||||
|
|
|
||||||
20
features/messages/impl/src/main/AndroidManifest.xml
Normal file
20
features/messages/impl/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
~ Copyright (c) 2023 New Vector Ltd
|
||||||
|
~
|
||||||
|
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
~ you may not use this file except in compliance with the License.
|
||||||
|
~ You may obtain a copy of the License at
|
||||||
|
~
|
||||||
|
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
~
|
||||||
|
~ Unless required by applicable law or agreed to in writing, software
|
||||||
|
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
~ See the License for the specific language governing permissions and
|
||||||
|
~ limitations under the License.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
</manifest>
|
||||||
|
|
@ -63,6 +63,7 @@ import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||||
|
|
@ -101,6 +102,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
private val preferencesStore: PreferencesStore,
|
private val preferencesStore: PreferencesStore,
|
||||||
private val featureFlagsService: FeatureFlagService,
|
private val featureFlagsService: FeatureFlagService,
|
||||||
@Assisted private val navigator: MessagesNavigator,
|
@Assisted private val navigator: MessagesNavigator,
|
||||||
|
private val buildMeta: BuildMeta,
|
||||||
) : Presenter<MessagesState> {
|
) : Presenter<MessagesState> {
|
||||||
|
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
|
|
@ -203,6 +205,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
enableTextFormatting = enableTextFormatting,
|
enableTextFormatting = enableTextFormatting,
|
||||||
enableVoiceMessages = enableVoiceMessages,
|
enableVoiceMessages = enableVoiceMessages,
|
||||||
enableInRoomCalls = enableInRoomCalls,
|
enableInRoomCalls = enableInRoomCalls,
|
||||||
|
appName = buildMeta.applicationName,
|
||||||
eventSink = { handleEvents(it) }
|
eventSink = { handleEvents(it) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,5 +50,6 @@ data class MessagesState(
|
||||||
val enableTextFormatting: Boolean,
|
val enableTextFormatting: Boolean,
|
||||||
val enableVoiceMessages: Boolean,
|
val enableVoiceMessages: Boolean,
|
||||||
val enableInRoomCalls: Boolean,
|
val enableInRoomCalls: Boolean,
|
||||||
|
val appName: String,
|
||||||
val eventSink: (MessagesEvents) -> Unit
|
val eventSink: (MessagesEvents) -> Unit
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -86,5 +86,6 @@ fun aMessagesState() = MessagesState(
|
||||||
enableTextFormatting = true,
|
enableTextFormatting = true,
|
||||||
enableVoiceMessages = true,
|
enableVoiceMessages = true,
|
||||||
enableInRoomCalls = true,
|
enableInRoomCalls = true,
|
||||||
|
appName = "Element",
|
||||||
eventSink = {}
|
eventSink = {}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,8 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum
|
||||||
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuEvents
|
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuEvents
|
||||||
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMessageMenu
|
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMessageMenu
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
|
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerEvents
|
||||||
|
import io.element.android.features.messages.impl.voicemessages.VoiceMessagePermissionRationaleDialog
|
||||||
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
|
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
|
||||||
import io.element.android.libraries.androidutils.ui.hideKeyboard
|
import io.element.android.libraries.androidutils.ui.hideKeyboard
|
||||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule
|
import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule
|
||||||
|
|
@ -83,6 +85,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||||
import io.element.android.libraries.designsystem.utils.LogCompositions
|
import io.element.android.libraries.designsystem.utils.LogCompositions
|
||||||
|
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
|
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
|
@ -107,6 +110,10 @@ fun MessagesView(
|
||||||
) {
|
) {
|
||||||
LogCompositions(tag = "MessagesScreen", msg = "Root")
|
LogCompositions(tag = "MessagesScreen", msg = "Root")
|
||||||
|
|
||||||
|
OnLifecycleEvent { _, event ->
|
||||||
|
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.LifecycleEvent(event))
|
||||||
|
}
|
||||||
|
|
||||||
AttachmentStateView(
|
AttachmentStateView(
|
||||||
state = state.composerState.attachmentsState,
|
state = state.composerState.attachmentsState,
|
||||||
onPreviewAttachments = onPreviewAttachments,
|
onPreviewAttachments = onPreviewAttachments,
|
||||||
|
|
@ -306,6 +313,18 @@ private fun MessagesViewContent(
|
||||||
enableTextFormatting = state.enableTextFormatting,
|
enableTextFormatting = state.enableTextFormatting,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (state.enableVoiceMessages && state.voiceMessageComposerState.showPermissionRationaleDialog) {
|
||||||
|
VoiceMessagePermissionRationaleDialog(
|
||||||
|
onContinue = {
|
||||||
|
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.AcceptPermissionRationale)
|
||||||
|
},
|
||||||
|
onDismiss = {
|
||||||
|
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.DismissPermissionsRationale)
|
||||||
|
},
|
||||||
|
appName = state.appName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
ExpandableBottomSheetScaffold(
|
ExpandableBottomSheetScaffold(
|
||||||
sheetDragHandle = if (state.composerState.showTextFormatting) {
|
sheetDragHandle = if (state.composerState.showTextFormatting) {
|
||||||
@Composable { BottomSheetDragHandle() }
|
@Composable { BottomSheetDragHandle() }
|
||||||
|
|
|
||||||
|
|
@ -71,10 +71,14 @@ internal fun MessageComposerView(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onVoiceRecordButtonEvent(press: PressEvent) {
|
val onVoiceRecordButtonEvent = { press: PressEvent ->
|
||||||
voiceMessageState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(press))
|
voiceMessageState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(press))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onSendVoiceMessage() {
|
||||||
|
voiceMessageState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||||
|
}
|
||||||
|
|
||||||
TextComposer(
|
TextComposer(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
state = state.richTextEditorState,
|
state = state.richTextEditorState,
|
||||||
|
|
@ -89,7 +93,8 @@ internal fun MessageComposerView(
|
||||||
onDismissTextFormatting = ::onDismissTextFormatting,
|
onDismissTextFormatting = ::onDismissTextFormatting,
|
||||||
enableTextFormatting = enableTextFormatting,
|
enableTextFormatting = enableTextFormatting,
|
||||||
enableVoiceMessages = enableVoiceMessages,
|
enableVoiceMessages = enableVoiceMessages,
|
||||||
onVoiceRecordButtonEvent = ::onVoiceRecordButtonEvent,
|
onVoiceRecordButtonEvent = onVoiceRecordButtonEvent,
|
||||||
|
onSendVoiceMessage = ::onSendVoiceMessage,
|
||||||
onError = ::onError,
|
onError = ::onError,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,15 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.voicemessages
|
package io.element.android.features.messages.impl.voicemessages
|
||||||
|
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
import io.element.android.libraries.textcomposer.model.PressEvent
|
import io.element.android.libraries.textcomposer.model.PressEvent
|
||||||
|
|
||||||
sealed interface VoiceMessageComposerEvents {
|
sealed interface VoiceMessageComposerEvents {
|
||||||
data class RecordButtonEvent(
|
data class RecordButtonEvent(
|
||||||
val pressEvent: PressEvent
|
val pressEvent: PressEvent
|
||||||
): VoiceMessageComposerEvents
|
): VoiceMessageComposerEvents
|
||||||
|
data object SendVoiceMessage: VoiceMessageComposerEvents
|
||||||
|
data object AcceptPermissionRationale: VoiceMessageComposerEvents
|
||||||
|
data object DismissPermissionsRationale: VoiceMessageComposerEvents
|
||||||
|
data class LifecycleEvent(val event: Lifecycle.Event): VoiceMessageComposerEvents
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,49 +16,171 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.voicemessages
|
package io.element.android.features.messages.impl.voicemessages
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
import io.element.android.libraries.di.SingleIn
|
import io.element.android.libraries.di.SingleIn
|
||||||
|
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||||
|
import io.element.android.libraries.permissions.api.PermissionsEvents
|
||||||
|
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||||
import io.element.android.libraries.textcomposer.model.PressEvent
|
import io.element.android.libraries.textcomposer.model.PressEvent
|
||||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||||
|
import io.element.android.libraries.voicerecorder.api.VoiceRecorder
|
||||||
|
import io.element.android.libraries.voicerecorder.api.VoiceRecorderState
|
||||||
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@SingleIn(RoomScope::class)
|
@SingleIn(RoomScope::class)
|
||||||
class VoiceMessageComposerPresenter @Inject constructor() : Presenter<VoiceMessageComposerState> {
|
class VoiceMessageComposerPresenter @Inject constructor(
|
||||||
|
private val appCoroutineScope: CoroutineScope,
|
||||||
|
private val voiceRecorder: VoiceRecorder,
|
||||||
|
private val analyticsService: AnalyticsService,
|
||||||
|
private val mediaSender: MediaSender,
|
||||||
|
permissionsPresenterFactory: PermissionsPresenter.Factory
|
||||||
|
) : Presenter<VoiceMessageComposerState> {
|
||||||
|
private val permissionsPresenter = permissionsPresenterFactory.create(Manifest.permission.RECORD_AUDIO)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): VoiceMessageComposerState {
|
override fun present(): VoiceMessageComposerState {
|
||||||
var voiceMessageState by remember { mutableStateOf<VoiceMessageState>(VoiceMessageState.Idle) }
|
val localCoroutineScope = rememberCoroutineScope()
|
||||||
|
val recorderState by voiceRecorder.state.collectAsState(initial = VoiceRecorderState.Idle)
|
||||||
|
|
||||||
fun onRecordButtonPress(event: VoiceMessageComposerEvents.RecordButtonEvent) = when(event.pressEvent) {
|
val permissionState = permissionsPresenter.present()
|
||||||
PressEvent.PressStart -> {
|
var isSending by remember { mutableStateOf(false) }
|
||||||
// TODO start the recording
|
|
||||||
voiceMessageState = VoiceMessageState.Recording
|
val onLifecycleEvent = { event: Lifecycle.Event ->
|
||||||
}
|
when (event) {
|
||||||
PressEvent.LongPressEnd -> {
|
Lifecycle.Event.ON_PAUSE -> {
|
||||||
// TODO finish the recording
|
appCoroutineScope.finishRecording()
|
||||||
voiceMessageState = VoiceMessageState.Idle
|
}
|
||||||
}
|
Lifecycle.Event.ON_DESTROY -> {
|
||||||
PressEvent.Tapped -> {
|
appCoroutineScope.cancelRecording()
|
||||||
// TODO discard the recording and show the 'hold to record' tooltip
|
}
|
||||||
voiceMessageState = VoiceMessageState.Idle
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val onRecordButtonPress = { event: VoiceMessageComposerEvents.RecordButtonEvent ->
|
||||||
|
val permissionGranted = permissionState.permissionGranted
|
||||||
|
when (event.pressEvent) {
|
||||||
|
PressEvent.PressStart -> {
|
||||||
|
Timber.v("Voice message record button pressed")
|
||||||
|
when {
|
||||||
|
permissionGranted -> {
|
||||||
|
localCoroutineScope.startRecording()
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Timber.i("Voice message permission needed")
|
||||||
|
permissionState.eventSink(PermissionsEvents.RequestPermissions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PressEvent.LongPressEnd -> {
|
||||||
|
Timber.v("Voice message record button released")
|
||||||
|
localCoroutineScope.finishRecording()
|
||||||
|
}
|
||||||
|
PressEvent.Tapped -> {
|
||||||
|
Timber.v("Voice message record button tapped")
|
||||||
|
localCoroutineScope.cancelRecording()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun handleEvents(event: VoiceMessageComposerEvents) {
|
val onAcceptPermissionsRationale = {
|
||||||
|
permissionState.eventSink(PermissionsEvents.OpenSystemSettingAndCloseDialog)
|
||||||
|
}
|
||||||
|
|
||||||
|
val onDismissPermissionsRationale = {
|
||||||
|
permissionState.eventSink(PermissionsEvents.CloseDialog)
|
||||||
|
}
|
||||||
|
|
||||||
|
val onSendButtonPress = lambda@{
|
||||||
|
val finishedState = recorderState as? VoiceRecorderState.Finished
|
||||||
|
if (finishedState == null) {
|
||||||
|
val exception = VoiceMessageException.FileException("No file to send")
|
||||||
|
analyticsService.trackError(exception)
|
||||||
|
Timber.e(exception)
|
||||||
|
return@lambda
|
||||||
|
}
|
||||||
|
if (isSending) {
|
||||||
|
return@lambda
|
||||||
|
}
|
||||||
|
isSending = true
|
||||||
|
appCoroutineScope.sendMessage(
|
||||||
|
file = finishedState.file,
|
||||||
|
mimeType = finishedState.mimeType,
|
||||||
|
).invokeOnCompletion {
|
||||||
|
isSending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val handleEvents: (VoiceMessageComposerEvents) -> Unit = { event ->
|
||||||
when (event) {
|
when (event) {
|
||||||
is VoiceMessageComposerEvents.RecordButtonEvent -> onRecordButtonPress(event)
|
is VoiceMessageComposerEvents.RecordButtonEvent -> onRecordButtonPress(event)
|
||||||
|
is VoiceMessageComposerEvents.SendVoiceMessage -> localCoroutineScope.launch {
|
||||||
|
onSendButtonPress()
|
||||||
|
}
|
||||||
|
VoiceMessageComposerEvents.DismissPermissionsRationale -> onDismissPermissionsRationale()
|
||||||
|
VoiceMessageComposerEvents.AcceptPermissionRationale -> onAcceptPermissionsRationale()
|
||||||
|
is VoiceMessageComposerEvents.LifecycleEvent -> onLifecycleEvent(event.event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return VoiceMessageComposerState(
|
return VoiceMessageComposerState(
|
||||||
voiceMessageState = voiceMessageState,
|
voiceMessageState = when (val state = recorderState) {
|
||||||
eventSink = { handleEvents(it) }
|
is VoiceRecorderState.Recording -> VoiceMessageState.Recording(level = state.level)
|
||||||
|
is VoiceRecorderState.Finished -> VoiceMessageState.Preview
|
||||||
|
else -> VoiceMessageState.Idle
|
||||||
|
},
|
||||||
|
showPermissionRationaleDialog = permissionState.showDialog,
|
||||||
|
eventSink = handleEvents,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun CoroutineScope.startRecording() = launch {
|
||||||
|
try {
|
||||||
|
voiceRecorder.startRecord()
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Timber.e(e, "Voice message error")
|
||||||
|
analyticsService.trackError(VoiceMessageException.PermissionMissing("Expected permission to record but none", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CoroutineScope.finishRecording() = launch {
|
||||||
|
voiceRecorder.stopRecord()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CoroutineScope.cancelRecording() = launch {
|
||||||
|
voiceRecorder.stopRecord(cancelled = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CoroutineScope.sendMessage(
|
||||||
|
file: File, mimeType: String,
|
||||||
|
) = launch {
|
||||||
|
val result = mediaSender.sendVoiceMessage(
|
||||||
|
uri = file.toUri(),
|
||||||
|
mimeType = mimeType,
|
||||||
|
waveForm = emptyList(), // TODO generate waveform
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.isFailure) {
|
||||||
|
Timber.e(result.exceptionOrNull(), "Voice message error")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
voiceRecorder.deleteRecording()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||||
@Stable
|
@Stable
|
||||||
data class VoiceMessageComposerState(
|
data class VoiceMessageComposerState(
|
||||||
val voiceMessageState: VoiceMessageState,
|
val voiceMessageState: VoiceMessageState,
|
||||||
|
val showPermissionRationaleDialog: Boolean,
|
||||||
val eventSink: (VoiceMessageComposerEvents) -> Unit,
|
val eventSink: (VoiceMessageComposerEvents) -> Unit,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||||
internal open class VoiceMessageComposerStateProvider : PreviewParameterProvider<VoiceMessageComposerState> {
|
internal open class VoiceMessageComposerStateProvider : PreviewParameterProvider<VoiceMessageComposerState> {
|
||||||
override val values: Sequence<VoiceMessageComposerState>
|
override val values: Sequence<VoiceMessageComposerState>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aVoiceMessageComposerState(voiceMessageState = VoiceMessageState.Recording),
|
aVoiceMessageComposerState(voiceMessageState = VoiceMessageState.Recording(level = 0.5)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -30,5 +30,6 @@ internal fun aVoiceMessageComposerState(
|
||||||
voiceMessageState: VoiceMessageState = VoiceMessageState.Idle,
|
voiceMessageState: VoiceMessageState = VoiceMessageState.Idle,
|
||||||
) = VoiceMessageComposerState(
|
) = VoiceMessageComposerState(
|
||||||
voiceMessageState = voiceMessageState,
|
voiceMessageState = voiceMessageState,
|
||||||
|
showPermissionRationaleDialog = false,
|
||||||
eventSink = {},
|
eventSink = {},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.voicemessages
|
||||||
|
|
||||||
|
internal sealed class VoiceMessageException : Exception() {
|
||||||
|
data class FileException(
|
||||||
|
override val message: String?, override val cause: Throwable? = null
|
||||||
|
) : VoiceMessageException()
|
||||||
|
data class PermissionMissing(
|
||||||
|
override val message: String?, override val cause: Throwable?
|
||||||
|
) : VoiceMessageException()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.voicemessages
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun VoiceMessagePermissionRationaleDialog(
|
||||||
|
onContinue: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
appName: String,
|
||||||
|
) {
|
||||||
|
ConfirmationDialog(
|
||||||
|
content = stringResource(CommonStrings.error_missing_microphone_voice_rationale_android, appName),
|
||||||
|
onSubmitClicked = onContinue,
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
submitText = stringResource(CommonStrings.action_continue),
|
||||||
|
cancelText = stringResource(CommonStrings.action_cancel),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -66,6 +66,7 @@ import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
|
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
|
||||||
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||||
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
||||||
|
|
@ -75,6 +76,7 @@ import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
||||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
||||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||||
|
import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder
|
||||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||||
|
|
@ -607,20 +609,28 @@ class MessagesPresenterTest {
|
||||||
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
|
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
|
||||||
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
||||||
): MessagesPresenter {
|
): MessagesPresenter {
|
||||||
|
val mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom)
|
||||||
|
val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter)
|
||||||
val messageComposerPresenter = MessageComposerPresenter(
|
val messageComposerPresenter = MessageComposerPresenter(
|
||||||
appCoroutineScope = this,
|
appCoroutineScope = this,
|
||||||
room = matrixRoom,
|
room = matrixRoom,
|
||||||
mediaPickerProvider = FakePickerProvider(),
|
mediaPickerProvider = FakePickerProvider(),
|
||||||
featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.NotificationSettings.key to true)),
|
featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.NotificationSettings.key to true)),
|
||||||
localMediaFactory = FakeLocalMediaFactory(mockMediaUrl),
|
localMediaFactory = FakeLocalMediaFactory(mockMediaUrl),
|
||||||
mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom),
|
mediaSender = mediaSender,
|
||||||
snackbarDispatcher = SnackbarDispatcher(),
|
snackbarDispatcher = SnackbarDispatcher(),
|
||||||
analyticsService = analyticsService,
|
analyticsService = analyticsService,
|
||||||
messageComposerContext = MessageComposerContextImpl(),
|
messageComposerContext = MessageComposerContextImpl(),
|
||||||
richTextEditorStateFactory = TestRichTextEditorStateFactory(),
|
richTextEditorStateFactory = TestRichTextEditorStateFactory(),
|
||||||
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
|
permissionsPresenterFactory = permissionsPresenterFactory,
|
||||||
|
)
|
||||||
|
val voiceMessageComposerPresenter = VoiceMessageComposerPresenter(
|
||||||
|
this,
|
||||||
|
FakeVoiceRecorder(),
|
||||||
|
analyticsService,
|
||||||
|
mediaSender,
|
||||||
|
permissionsPresenterFactory,
|
||||||
)
|
)
|
||||||
val voiceMessageComposerPresenter = VoiceMessageComposerPresenter()
|
|
||||||
val timelinePresenter = TimelinePresenter(
|
val timelinePresenter = TimelinePresenter(
|
||||||
timelineItemsFactory = aTimelineItemsFactory(),
|
timelineItemsFactory = aTimelineItemsFactory(),
|
||||||
room = matrixRoom,
|
room = matrixRoom,
|
||||||
|
|
@ -649,6 +659,7 @@ class MessagesPresenterTest {
|
||||||
clipboardHelper = clipboardHelper,
|
clipboardHelper = clipboardHelper,
|
||||||
preferencesStore = preferencesStore,
|
preferencesStore = preferencesStore,
|
||||||
featureFlagsService = FakeFeatureFlagService(),
|
featureFlagsService = FakeFeatureFlagService(),
|
||||||
|
buildMeta = aBuildMeta(),
|
||||||
dispatchers = coroutineDispatchers,
|
dispatchers = coroutineDispatchers,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,16 +18,31 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.voicemessages
|
package io.element.android.features.messages.voicemessages
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
import app.cash.molecule.RecompositionMode
|
import app.cash.molecule.RecompositionMode
|
||||||
import app.cash.molecule.moleculeFlow
|
import app.cash.molecule.moleculeFlow
|
||||||
|
import app.cash.turbine.TurbineTestContext
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerEvents
|
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerEvents
|
||||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerPresenter
|
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerPresenter
|
||||||
|
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerState
|
||||||
|
import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
|
||||||
|
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
|
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||||
|
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
||||||
|
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||||
|
import io.element.android.libraries.permissions.api.aPermissionsState
|
||||||
|
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
||||||
|
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
||||||
import io.element.android.libraries.textcomposer.model.PressEvent
|
import io.element.android.libraries.textcomposer.model.PressEvent
|
||||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||||
|
import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder
|
||||||
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -37,53 +52,349 @@ class VoiceMessageComposerPresenterTest {
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val warmUpRule = WarmUpRule()
|
val warmUpRule = WarmUpRule()
|
||||||
|
|
||||||
|
private val voiceRecorder = FakeVoiceRecorder()
|
||||||
|
private val analyticsService = FakeAnalyticsService()
|
||||||
|
private val matrixRoom = FakeMatrixRoom()
|
||||||
|
private val mediaPreProcessor = FakeMediaPreProcessor().apply { givenAudioResult() }
|
||||||
|
private val mediaSender = MediaSender(mediaPreProcessor, matrixRoom)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - initial state`() = runTest {
|
fun `present - initial state`() = runTest {
|
||||||
val presenter = createPresenter()
|
val presenter = createVoiceMessageComposerPresenter()
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||||
|
|
||||||
|
testPauseAndDestroy(initialState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - recording state`() = runTest {
|
fun `present - recording state`() = runTest {
|
||||||
val presenter = createPresenter()
|
val presenter = createVoiceMessageComposerPresenter()
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||||
|
|
||||||
assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Recording)
|
val finalState = awaitItem()
|
||||||
|
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Recording(0.2))
|
||||||
|
|
||||||
|
testPauseAndDestroy(finalState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - abort recording`() = runTest {
|
fun `present - abort recording`() = runTest {
|
||||||
val presenter = createPresenter()
|
val presenter = createVoiceMessageComposerPresenter()
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.Tapped))
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.Tapped))
|
||||||
|
|
||||||
assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
val finalState = awaitItem()
|
||||||
|
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||||
|
|
||||||
|
testPauseAndDestroy(finalState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - finish recording`() = runTest {
|
fun `present - finish recording`() = runTest {
|
||||||
val presenter = createPresenter()
|
val presenter = createVoiceMessageComposerPresenter()
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||||
|
|
||||||
assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
val finalState = awaitItem()
|
||||||
|
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Preview)
|
||||||
|
|
||||||
|
testPauseAndDestroy(finalState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private fun createPresenter() = VoiceMessageComposerPresenter()
|
|
||||||
|
@Test
|
||||||
|
fun `present - send recording`() = runTest {
|
||||||
|
val presenter = createVoiceMessageComposerPresenter()
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||||
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||||
|
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||||
|
|
||||||
|
val finalState = awaitItem()
|
||||||
|
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||||
|
assertThat(matrixRoom.sendMediaCount).isEqualTo(1)
|
||||||
|
|
||||||
|
testPauseAndDestroy(finalState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - send recording before previous completed, waits`() = runTest {
|
||||||
|
val presenter = createVoiceMessageComposerPresenter()
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||||
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||||
|
awaitItem().run {
|
||||||
|
eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||||
|
eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
val finalState = awaitItem()
|
||||||
|
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||||
|
assertThat(matrixRoom.sendMediaCount).isEqualTo(1)
|
||||||
|
|
||||||
|
testPauseAndDestroy(finalState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - send failures aren't tracked`() = runTest {
|
||||||
|
// Let sending fail due to media preprocessing error
|
||||||
|
mediaPreProcessor.givenResult(Result.failure(Exception()))
|
||||||
|
val presenter = createVoiceMessageComposerPresenter()
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||||
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||||
|
|
||||||
|
val finalState = awaitItem().apply {
|
||||||
|
assertThat(voiceMessageState).isEqualTo(VoiceMessageState.Preview)
|
||||||
|
eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||||
|
}
|
||||||
|
assertThat(matrixRoom.sendMediaCount).isEqualTo(0)
|
||||||
|
assertThat(analyticsService.trackedErrors).hasSize(0)
|
||||||
|
|
||||||
|
testPauseAndDestroy(finalState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - send failures can be retried`() = runTest {
|
||||||
|
// Let sending fail due to media preprocessing error
|
||||||
|
val presenter = createVoiceMessageComposerPresenter()
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
mediaPreProcessor.givenResult(Result.failure(Exception()))
|
||||||
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||||
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||||
|
val previewState = awaitItem()
|
||||||
|
|
||||||
|
previewState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||||
|
|
||||||
|
ensureAllEventsConsumed()
|
||||||
|
assertThat(previewState.voiceMessageState).isEqualTo(VoiceMessageState.Preview)
|
||||||
|
assertThat(matrixRoom.sendMediaCount).isEqualTo(0)
|
||||||
|
|
||||||
|
mediaPreProcessor.givenAudioResult()
|
||||||
|
previewState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||||
|
val finalState = awaitItem()
|
||||||
|
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||||
|
assertThat(matrixRoom.sendMediaCount).isEqualTo(1)
|
||||||
|
|
||||||
|
testPauseAndDestroy(finalState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - send error - missing recording is tracked`() = runTest {
|
||||||
|
val presenter = createVoiceMessageComposerPresenter()
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
// Send the message before recording anything
|
||||||
|
initialState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||||
|
|
||||||
|
assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||||
|
assertThat(matrixRoom.sendMediaCount).isEqualTo(0)
|
||||||
|
assertThat(analyticsService.trackedErrors).hasSize(1)
|
||||||
|
|
||||||
|
testPauseAndDestroy(initialState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - record error - security exceptions are tracked`() = runTest {
|
||||||
|
val exception = SecurityException("")
|
||||||
|
voiceRecorder.givenThrowsSecurityException(exception)
|
||||||
|
val presenter = createVoiceMessageComposerPresenter()
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||||
|
|
||||||
|
assertThat(matrixRoom.sendMediaCount).isEqualTo(0)
|
||||||
|
assertThat(analyticsService.trackedErrors).containsExactly(
|
||||||
|
VoiceMessageException.PermissionMissing(message = "Expected permission to record but none", cause = exception)
|
||||||
|
)
|
||||||
|
|
||||||
|
testPauseAndDestroy(initialState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - permission accepted first time`() = runTest {
|
||||||
|
val permissionsPresenter = createFakePermissionsPresenter(
|
||||||
|
recordPermissionGranted = false,
|
||||||
|
)
|
||||||
|
val presenter = createVoiceMessageComposerPresenter(
|
||||||
|
permissionsPresenter = permissionsPresenter,
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||||
|
assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||||
|
|
||||||
|
initialState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||||
|
permissionsPresenter.setPermissionGranted()
|
||||||
|
|
||||||
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||||
|
val finalState = awaitItem()
|
||||||
|
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Recording(0.2))
|
||||||
|
|
||||||
|
testPauseAndDestroy(finalState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - permission denied previously`() = runTest {
|
||||||
|
val permissionsPresenter = createFakePermissionsPresenter(
|
||||||
|
recordPermissionGranted = false,
|
||||||
|
)
|
||||||
|
val presenter = createVoiceMessageComposerPresenter(
|
||||||
|
permissionsPresenter = permissionsPresenter,
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||||
|
|
||||||
|
// See the dialog and accept it
|
||||||
|
awaitItem().also {
|
||||||
|
assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||||
|
assertThat(it.showPermissionRationaleDialog).isTrue()
|
||||||
|
it.eventSink(VoiceMessageComposerEvents.AcceptPermissionRationale)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dialog is hidden, user accepts permissions
|
||||||
|
assertThat(awaitItem().showPermissionRationaleDialog).isFalse()
|
||||||
|
|
||||||
|
permissionsPresenter.setPermissionGranted()
|
||||||
|
|
||||||
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||||
|
val finalState = awaitItem()
|
||||||
|
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Recording(0.2))
|
||||||
|
|
||||||
|
testPauseAndDestroy(finalState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - permission rationale dismissed`() = runTest {
|
||||||
|
val permissionsPresenter = createFakePermissionsPresenter(
|
||||||
|
recordPermissionGranted = false,
|
||||||
|
)
|
||||||
|
val presenter = createVoiceMessageComposerPresenter(
|
||||||
|
permissionsPresenter = permissionsPresenter,
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||||
|
|
||||||
|
// See the dialog and accept it
|
||||||
|
awaitItem().also {
|
||||||
|
assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||||
|
assertThat(it.showPermissionRationaleDialog).isTrue()
|
||||||
|
it.eventSink(VoiceMessageComposerEvents.DismissPermissionsRationale)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dialog is hidden, user tries to record again
|
||||||
|
awaitItem().also {
|
||||||
|
assertThat(it.showPermissionRationaleDialog).isFalse()
|
||||||
|
it.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dialog is shown once again
|
||||||
|
val finalState = awaitItem().also {
|
||||||
|
assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||||
|
assertThat(it.showPermissionRationaleDialog).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
testPauseAndDestroy(finalState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun TurbineTestContext<VoiceMessageComposerState>.testPauseAndDestroy(
|
||||||
|
mostRecentState: VoiceMessageComposerState,
|
||||||
|
) {
|
||||||
|
mostRecentState.eventSink(
|
||||||
|
VoiceMessageComposerEvents.LifecycleEvent(event = Lifecycle.Event.ON_PAUSE)
|
||||||
|
)
|
||||||
|
|
||||||
|
val onPauseState = when (mostRecentState.voiceMessageState) {
|
||||||
|
VoiceMessageState.Idle,
|
||||||
|
VoiceMessageState.Preview -> {
|
||||||
|
mostRecentState
|
||||||
|
}
|
||||||
|
is VoiceMessageState.Recording -> {
|
||||||
|
awaitItem().also {
|
||||||
|
assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Preview)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onPauseState.eventSink(
|
||||||
|
VoiceMessageComposerEvents.LifecycleEvent(event = Lifecycle.Event.ON_DESTROY)
|
||||||
|
)
|
||||||
|
|
||||||
|
when (onPauseState.voiceMessageState) {
|
||||||
|
VoiceMessageState.Idle ->
|
||||||
|
ensureAllEventsConsumed()
|
||||||
|
is VoiceMessageState.Recording,
|
||||||
|
VoiceMessageState.Preview ->
|
||||||
|
assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TestScope.createVoiceMessageComposerPresenter(
|
||||||
|
permissionsPresenter: PermissionsPresenter = createFakePermissionsPresenter(),
|
||||||
|
): VoiceMessageComposerPresenter {
|
||||||
|
return VoiceMessageComposerPresenter(
|
||||||
|
this,
|
||||||
|
voiceRecorder,
|
||||||
|
analyticsService,
|
||||||
|
mediaSender,
|
||||||
|
FakePermissionsPresenterFactory(permissionsPresenter),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createFakePermissionsPresenter(
|
||||||
|
recordPermissionGranted: Boolean = true,
|
||||||
|
recordPermissionShowDialog: Boolean = false,
|
||||||
|
): FakePermissionsPresenter {
|
||||||
|
val initialPermissionState = aPermissionsState(
|
||||||
|
showDialog = recordPermissionShowDialog,
|
||||||
|
permission = Manifest.permission.RECORD_AUDIO,
|
||||||
|
permissionGranted = recordPermissionGranted,
|
||||||
|
)
|
||||||
|
return FakePermissionsPresenter(
|
||||||
|
initialState = initialPermissionState
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ google_firebase_bom = "com.google.firebase:firebase-bom:32.4.0"
|
||||||
# AndroidX
|
# AndroidX
|
||||||
androidx_core = { module = "androidx.core:core", version.ref = "core" }
|
androidx_core = { module = "androidx.core:core", version.ref = "core" }
|
||||||
androidx_corektx = { module = "androidx.core:core-ktx", version.ref = "core" }
|
androidx_corektx = { module = "androidx.core:core-ktx", version.ref = "core" }
|
||||||
|
androidx_annotationjvm = { module = "androidx.annotation:annotation-jvm", version = "1.7.0" }
|
||||||
androidx_datastore_preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
|
androidx_datastore_preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
|
||||||
androidx_datastore_datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
|
androidx_datastore_datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
|
||||||
androidx_exifinterface = "androidx.exifinterface:exifinterface:1.3.6"
|
androidx_exifinterface = "androidx.exifinterface:exifinterface:1.3.6"
|
||||||
|
|
@ -164,6 +165,7 @@ statemachine = "com.freeletics.flowredux:compose:1.2.0"
|
||||||
maplibre = "org.maplibre.gl:android-sdk:10.2.0"
|
maplibre = "org.maplibre.gl:android-sdk:10.2.0"
|
||||||
maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:2.0.1"
|
maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:2.0.1"
|
||||||
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:2.0.1"
|
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:2.0.1"
|
||||||
|
opusencoder = "io.element.android:opusencoder:1.1.0"
|
||||||
|
|
||||||
# Analytics
|
# Analytics
|
||||||
posthog = "com.posthog.android:posthog:2.0.3"
|
posthog = "com.posthog.android:posthog:2.0.3"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.core.hash
|
||||||
|
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute a Hash of a String, using md5 algorithm.
|
||||||
|
*/
|
||||||
|
fun String.md5() = try {
|
||||||
|
val digest = MessageDigest.getInstance("md5")
|
||||||
|
val locale = Locale.ROOT
|
||||||
|
digest.update(toByteArray())
|
||||||
|
digest.digest()
|
||||||
|
.joinToString("") { String.format(locale, "%02X", it) }
|
||||||
|
.lowercase(locale)
|
||||||
|
} catch (exc: Exception) {
|
||||||
|
// Should not happen, but just in case
|
||||||
|
hashCode().toString()
|
||||||
|
}
|
||||||
|
|
@ -50,16 +50,43 @@ class MediaSender @Inject constructor(
|
||||||
.flatMapCatching { info ->
|
.flatMapCatching { info ->
|
||||||
room.sendMedia(info, progressCallback)
|
room.sendMedia(info, progressCallback)
|
||||||
}
|
}
|
||||||
.onFailure { error ->
|
.handleSendResult()
|
||||||
val job = ongoingUploadJobs.remove(Job)
|
|
||||||
if (error !is CancellationException) {
|
|
||||||
job?.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onSuccess {
|
|
||||||
ongoingUploadJobs.remove(Job)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
suspend fun sendVoiceMessage(
|
||||||
|
uri: Uri,
|
||||||
|
mimeType: String,
|
||||||
|
waveForm: List<Int>,
|
||||||
|
progressCallback: ProgressCallback? = null
|
||||||
|
): Result<Unit> {
|
||||||
|
return preProcessor
|
||||||
|
.process(
|
||||||
|
uri = uri,
|
||||||
|
mimeType = mimeType,
|
||||||
|
deleteOriginal = true,
|
||||||
|
compressIfPossible = false
|
||||||
|
)
|
||||||
|
.flatMapCatching { info ->
|
||||||
|
val audioInfo = (info as MediaUploadInfo.Audio).audioInfo
|
||||||
|
val newInfo = MediaUploadInfo.VoiceMessage(
|
||||||
|
file = info.file,
|
||||||
|
audioInfo = audioInfo,
|
||||||
|
waveform = waveForm,
|
||||||
|
)
|
||||||
|
room.sendMedia(newInfo, progressCallback)
|
||||||
|
}
|
||||||
|
.handleSendResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Result<Unit>.handleSendResult() = this
|
||||||
|
.onFailure { error ->
|
||||||
|
val job = ongoingUploadJobs.remove(Job)
|
||||||
|
if (error !is CancellationException) {
|
||||||
|
job?.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onSuccess {
|
||||||
|
ongoingUploadJobs.remove(Job)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun MatrixRoom.sendMedia(
|
private suspend fun MatrixRoom.sendMedia(
|
||||||
uploadInfo: MediaUploadInfo,
|
uploadInfo: MediaUploadInfo,
|
||||||
|
|
@ -90,7 +117,14 @@ class MediaSender @Inject constructor(
|
||||||
progressCallback = progressCallback
|
progressCallback = progressCallback
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
is MediaUploadInfo.VoiceMessage -> {
|
||||||
|
sendVoiceMessage(
|
||||||
|
file = uploadInfo.file,
|
||||||
|
audioInfo = uploadInfo.audioInfo,
|
||||||
|
waveform = uploadInfo.waveform,
|
||||||
|
progressCallback = progressCallback
|
||||||
|
)
|
||||||
|
}
|
||||||
is MediaUploadInfo.AnyFile -> {
|
is MediaUploadInfo.AnyFile -> {
|
||||||
sendFile(
|
sendFile(
|
||||||
file = uploadInfo.file,
|
file = uploadInfo.file,
|
||||||
|
|
|
||||||
|
|
@ -29,5 +29,6 @@ sealed interface MediaUploadInfo {
|
||||||
data class Image(override val file: File, val imageInfo: ImageInfo, val thumbnailFile: File) : MediaUploadInfo
|
data class Image(override val file: File, val imageInfo: ImageInfo, val thumbnailFile: File) : MediaUploadInfo
|
||||||
data class Video(override val file: File, val videoInfo: VideoInfo, val thumbnailFile: File) : MediaUploadInfo
|
data class Video(override val file: File, val videoInfo: VideoInfo, val thumbnailFile: File) : MediaUploadInfo
|
||||||
data class Audio(override val file: File, val audioInfo: AudioInfo) : MediaUploadInfo
|
data class Audio(override val file: File, val audioInfo: AudioInfo) : MediaUploadInfo
|
||||||
|
data class VoiceMessage(override val file: File, val audioInfo: AudioInfo, val waveform: List<Int>) : MediaUploadInfo
|
||||||
data class AnyFile(override val file: File, val fileInfo: FileInfo) : MediaUploadInfo
|
data class AnyFile(override val file: File, val fileInfo: FileInfo) : MediaUploadInfo
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,7 @@ class AndroidMediaPreProcessor @Inject constructor(
|
||||||
is MediaUploadInfo.Audio -> copy(file = renamedFile)
|
is MediaUploadInfo.Audio -> copy(file = renamedFile)
|
||||||
is MediaUploadInfo.Image -> copy(file = renamedFile)
|
is MediaUploadInfo.Image -> copy(file = renamedFile)
|
||||||
is MediaUploadInfo.Video -> copy(file = renamedFile)
|
is MediaUploadInfo.Video -> copy(file = renamedFile)
|
||||||
|
is MediaUploadInfo.VoiceMessage -> copy(file = renamedFile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,14 @@
|
||||||
package io.element.android.libraries.mediaupload.test
|
package io.element.android.libraries.mediaupload.test
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import io.element.android.libraries.matrix.api.media.AudioInfo
|
||||||
import io.element.android.libraries.matrix.api.media.FileInfo
|
import io.element.android.libraries.matrix.api.media.FileInfo
|
||||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||||
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
||||||
import io.element.android.tests.testutils.simulateLongTask
|
import io.element.android.tests.testutils.simulateLongTask
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
import kotlin.time.toJavaDuration
|
||||||
|
|
||||||
class FakeMediaPreProcessor : MediaPreProcessor {
|
class FakeMediaPreProcessor : MediaPreProcessor {
|
||||||
|
|
||||||
|
|
@ -53,4 +56,19 @@ class FakeMediaPreProcessor : MediaPreProcessor {
|
||||||
fun givenResult(value: Result<MediaUploadInfo>) {
|
fun givenResult(value: Result<MediaUploadInfo>) {
|
||||||
this.result = value
|
this.result = value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun givenAudioResult() {
|
||||||
|
givenResult(
|
||||||
|
Result.success(
|
||||||
|
MediaUploadInfo.Audio(
|
||||||
|
file = File("audio.ogg"),
|
||||||
|
audioInfo = AudioInfo(
|
||||||
|
duration = 1000.seconds.toJavaDuration(),
|
||||||
|
size = 1000,
|
||||||
|
mimetype = "audio/ogg",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,11 @@ open class PermissionsStateProvider : PreviewParameterProvider<PermissionsState>
|
||||||
|
|
||||||
fun aPermissionsState(
|
fun aPermissionsState(
|
||||||
showDialog: Boolean,
|
showDialog: Boolean,
|
||||||
permission: String = Manifest.permission.POST_NOTIFICATIONS
|
permission: String = Manifest.permission.POST_NOTIFICATIONS,
|
||||||
|
permissionGranted: Boolean = false,
|
||||||
) = PermissionsState(
|
) = PermissionsState(
|
||||||
permission = permission,
|
permission = permission,
|
||||||
permissionGranted = false,
|
permissionGranted = permissionGranted,
|
||||||
shouldShowRationale = false,
|
shouldShowRationale = false,
|
||||||
showDialog = showDialog,
|
showDialog = showDialog,
|
||||||
permissionAlreadyAsked = false,
|
permissionAlreadyAsked = false,
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,8 @@ import io.element.android.libraries.testtags.testTag
|
||||||
import io.element.android.libraries.textcomposer.components.ComposerOptionsButton
|
import io.element.android.libraries.textcomposer.components.ComposerOptionsButton
|
||||||
import io.element.android.libraries.textcomposer.components.DismissTextFormattingButton
|
import io.element.android.libraries.textcomposer.components.DismissTextFormattingButton
|
||||||
import io.element.android.libraries.textcomposer.components.RecordButton
|
import io.element.android.libraries.textcomposer.components.RecordButton
|
||||||
import io.element.android.libraries.textcomposer.components.RecordingProgress
|
import io.element.android.libraries.textcomposer.components.VoiceMessagePreview
|
||||||
|
import io.element.android.libraries.textcomposer.components.VoiceMessageRecording
|
||||||
import io.element.android.libraries.textcomposer.components.SendButton
|
import io.element.android.libraries.textcomposer.components.SendButton
|
||||||
import io.element.android.libraries.textcomposer.components.TextFormatting
|
import io.element.android.libraries.textcomposer.components.TextFormatting
|
||||||
import io.element.android.libraries.textcomposer.components.textInputRoundedCornerShape
|
import io.element.android.libraries.textcomposer.components.textInputRoundedCornerShape
|
||||||
|
|
@ -95,6 +96,7 @@ fun TextComposer(
|
||||||
onAddAttachment: () -> Unit = {},
|
onAddAttachment: () -> Unit = {},
|
||||||
onDismissTextFormatting: () -> Unit = {},
|
onDismissTextFormatting: () -> Unit = {},
|
||||||
onVoiceRecordButtonEvent: (PressEvent) -> Unit = {},
|
onVoiceRecordButtonEvent: (PressEvent) -> Unit = {},
|
||||||
|
onSendVoiceMessage: () -> Unit = {},
|
||||||
onError: (Throwable) -> Unit = {},
|
onError: (Throwable) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val onSendClicked = {
|
val onSendClicked = {
|
||||||
|
|
@ -137,24 +139,39 @@ fun TextComposer(
|
||||||
composerMode = composerMode,
|
composerMode = composerMode,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val recordButton = @Composable {
|
val recordVoiceButton = @Composable {
|
||||||
RecordButton(
|
RecordButton(
|
||||||
onPressStart = { onVoiceRecordButtonEvent(PressEvent.PressStart) },
|
onPressStart = { onVoiceRecordButtonEvent(PressEvent.PressStart) },
|
||||||
onLongPressEnd = { onVoiceRecordButtonEvent(PressEvent.LongPressEnd) },
|
onLongPressEnd = { onVoiceRecordButtonEvent(PressEvent.LongPressEnd) },
|
||||||
onTap = { onVoiceRecordButtonEvent(PressEvent.Tapped) },
|
onTap = { onVoiceRecordButtonEvent(PressEvent.Tapped) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
val sendVoiceButton = @Composable {
|
||||||
|
SendButton(
|
||||||
|
canSendMessage = voiceMessageState is VoiceMessageState.Preview,
|
||||||
|
onClick = { onSendVoiceMessage() },
|
||||||
|
composerMode = composerMode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val textFormattingOptions = @Composable { TextFormatting(state = state) }
|
val textFormattingOptions = @Composable { TextFormatting(state = state) }
|
||||||
|
|
||||||
val sendOrRecordButton = if (canSendMessage || !enableVoiceMessages) {
|
val sendOrRecordButton = when {
|
||||||
sendButton
|
enableVoiceMessages && !canSendMessage ->
|
||||||
} else {
|
when (voiceMessageState) {
|
||||||
recordButton
|
is VoiceMessageState.Preview -> sendVoiceButton
|
||||||
|
else -> recordVoiceButton
|
||||||
|
}
|
||||||
|
else ->
|
||||||
|
sendButton
|
||||||
}
|
}
|
||||||
|
|
||||||
val recordingProgress = @Composable {
|
val voiceRecording = @Composable {
|
||||||
RecordingProgress()
|
if (voiceMessageState is VoiceMessageState.Recording) {
|
||||||
|
VoiceMessageRecording(voiceMessageState.level)
|
||||||
|
} else if (voiceMessageState is VoiceMessageState.Preview) {
|
||||||
|
VoiceMessagePreview()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showTextFormatting) {
|
if (showTextFormatting) {
|
||||||
|
|
@ -170,11 +187,12 @@ fun TextComposer(
|
||||||
} else {
|
} else {
|
||||||
StandardLayout(
|
StandardLayout(
|
||||||
voiceMessageState = voiceMessageState,
|
voiceMessageState = voiceMessageState,
|
||||||
|
enableVoiceMessages = enableVoiceMessages,
|
||||||
modifier = layoutModifier,
|
modifier = layoutModifier,
|
||||||
composerOptionsButton = composerOptionsButton,
|
composerOptionsButton = composerOptionsButton,
|
||||||
textInput = textInput,
|
textInput = textInput,
|
||||||
endButton = sendOrRecordButton,
|
endButton = sendOrRecordButton,
|
||||||
recordingProgress = recordingProgress,
|
voiceRecording = voiceRecording,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -190,9 +208,10 @@ fun TextComposer(
|
||||||
@Composable
|
@Composable
|
||||||
private fun StandardLayout(
|
private fun StandardLayout(
|
||||||
voiceMessageState: VoiceMessageState,
|
voiceMessageState: VoiceMessageState,
|
||||||
|
enableVoiceMessages: Boolean,
|
||||||
textInput: @Composable () -> Unit,
|
textInput: @Composable () -> Unit,
|
||||||
composerOptionsButton: @Composable () -> Unit,
|
composerOptionsButton: @Composable () -> Unit,
|
||||||
recordingProgress: @Composable () -> Unit,
|
voiceRecording: @Composable () -> Unit,
|
||||||
endButton: @Composable () -> Unit,
|
endButton: @Composable () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
|
@ -200,13 +219,13 @@ private fun StandardLayout(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
verticalAlignment = Alignment.Bottom,
|
verticalAlignment = Alignment.Bottom,
|
||||||
) {
|
) {
|
||||||
if (voiceMessageState is VoiceMessageState.Recording) {
|
if (enableVoiceMessages && voiceMessageState !is VoiceMessageState.Idle) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
|
.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
) {
|
) {
|
||||||
recordingProgress()
|
voiceRecording()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Box(
|
Box(
|
||||||
|
|
|
||||||
|
|
@ -17,14 +17,10 @@
|
||||||
package io.element.android.libraries.textcomposer.components
|
package io.element.android.libraries.textcomposer.components
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
|
@ -36,7 +32,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.theme.ElementTheme
|
import io.element.android.libraries.theme.ElementTheme
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun RecordingProgress(
|
internal fun VoiceMessagePreview(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -50,16 +46,9 @@ internal fun RecordingProgress(
|
||||||
.heightIn(26.dp),
|
.heightIn(26.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Box(
|
// TODO Replace with recording preview UI
|
||||||
modifier = Modifier
|
|
||||||
.size(8.dp)
|
|
||||||
.background(color = ElementTheme.colors.textCriticalPrimary, shape = CircleShape)
|
|
||||||
)
|
|
||||||
Spacer(Modifier.size(8.dp))
|
|
||||||
|
|
||||||
// TODO Replace with timer UI
|
|
||||||
Text(
|
Text(
|
||||||
text = "Recording...", // Not localized because it is a placeholder
|
text = "Finished recording", // Not localized because it is a placeholder
|
||||||
color = ElementTheme.colors.textSecondary,
|
color = ElementTheme.colors.textSecondary,
|
||||||
style = ElementTheme.typography.fontBodySmMedium
|
style = ElementTheme.typography.fontBodySmMedium
|
||||||
)
|
)
|
||||||
|
|
@ -68,6 +57,6 @@ internal fun RecordingProgress(
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
internal fun RecordingProgressPreview() = ElementPreview {
|
internal fun VoiceMessagePreviewPreview() = ElementPreview {
|
||||||
RecordingProgress()
|
VoiceMessagePreview()
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.textcomposer.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.theme.ElementTheme
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun VoiceMessageRecording(
|
||||||
|
level: Double,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(
|
||||||
|
color = ElementTheme.colors.bgSubtleSecondary,
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
)
|
||||||
|
.padding(start = 12.dp, end = 20.dp, top = 8.dp, bottom = 8.dp)
|
||||||
|
.heightIn(26.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.background(color = ElementTheme.colors.textCriticalPrimary, shape = CircleShape)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
|
||||||
|
// TODO Replace with timer UI
|
||||||
|
Text(
|
||||||
|
text = "Recording...", // Not localized because it is a placeholder
|
||||||
|
color = ElementTheme.colors.textSecondary,
|
||||||
|
style = ElementTheme.typography.fontBodySmMedium
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.size(20.dp))
|
||||||
|
|
||||||
|
// TODO Replace with waveform UI
|
||||||
|
DebugAudioLevel(
|
||||||
|
modifier = Modifier.weight(1f), level = level
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DebugAudioLevel(
|
||||||
|
level: Double,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.height(26.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.CenterEnd)
|
||||||
|
.fillMaxWidth(level.toFloat())
|
||||||
|
.background(ElementTheme.colors.iconQuaternary, shape = MaterialTheme.shapes.small)
|
||||||
|
.fillMaxHeight()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun VoiceMessageRecordingPreview() = ElementPreview {
|
||||||
|
VoiceMessageRecording(0.5)
|
||||||
|
}
|
||||||
|
|
@ -18,5 +18,9 @@ package io.element.android.libraries.textcomposer.model
|
||||||
|
|
||||||
sealed class VoiceMessageState {
|
sealed class VoiceMessageState {
|
||||||
data object Idle: VoiceMessageState()
|
data object Idle: VoiceMessageState()
|
||||||
data object Recording: VoiceMessageState()
|
|
||||||
|
data object Preview: VoiceMessageState()
|
||||||
|
data class Recording(
|
||||||
|
val level: Double,
|
||||||
|
): VoiceMessageState()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
32
libraries/voicerecorder/api/build.gradle.kts
Normal file
32
libraries/voicerecorder/api/build.gradle.kts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
plugins {
|
||||||
|
id("io.element.android-library")
|
||||||
|
alias(libs.plugins.anvil)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "io.element.android.libraries.voicerecorder.api"
|
||||||
|
}
|
||||||
|
|
||||||
|
anvil {
|
||||||
|
generateDaggerFactories.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.androidx.annotationjvm)
|
||||||
|
implementation(libs.coroutines.core)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.voicerecorder.api
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import androidx.annotation.RequiresPermission
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audio recorder which records audio to opus/ogg files.
|
||||||
|
*/
|
||||||
|
interface VoiceRecorder {
|
||||||
|
/**
|
||||||
|
* Start a recording.
|
||||||
|
*
|
||||||
|
* Call [stopRecord] to stop the recording and release resources.
|
||||||
|
*/
|
||||||
|
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
|
||||||
|
suspend fun startRecord()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the current recording.
|
||||||
|
*
|
||||||
|
* Call [deleteRecording] to delete any recorded audio.
|
||||||
|
*
|
||||||
|
* @param cancelled If true, the recording is deleted.
|
||||||
|
*/
|
||||||
|
suspend fun stopRecord(
|
||||||
|
cancelled: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the current recording and delete the output file.
|
||||||
|
*/
|
||||||
|
suspend fun deleteRecording()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current state of the recorder.
|
||||||
|
*/
|
||||||
|
val state: StateFlow<VoiceRecorderState>
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.voicerecorder.api
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
sealed class VoiceRecorderState {
|
||||||
|
/**
|
||||||
|
* The recorder is idle and not recording.
|
||||||
|
*/
|
||||||
|
data object Idle : VoiceRecorderState()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The recorder is currently recording.
|
||||||
|
*
|
||||||
|
* @property level The current audio level of the recording as a fraction of 1.
|
||||||
|
*/
|
||||||
|
data class Recording(val level: Double) : VoiceRecorderState()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The recorder has finished recording.
|
||||||
|
*
|
||||||
|
* @property file The recorded file.
|
||||||
|
* @property mimeType The mime type of the file.
|
||||||
|
*/
|
||||||
|
data class Finished(
|
||||||
|
val file: File,
|
||||||
|
val mimeType: String,
|
||||||
|
) : VoiceRecorderState()
|
||||||
|
}
|
||||||
48
libraries/voicerecorder/impl/build.gradle.kts
Normal file
48
libraries/voicerecorder/impl/build.gradle.kts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
plugins {
|
||||||
|
id("io.element.android-library")
|
||||||
|
alias(libs.plugins.anvil)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "io.element.android.libraries.voicerecorder.impl"
|
||||||
|
}
|
||||||
|
|
||||||
|
anvil {
|
||||||
|
generateDaggerFactories.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
api(projects.libraries.voicerecorder.api)
|
||||||
|
api(libs.opusencoder)
|
||||||
|
|
||||||
|
implementation(libs.dagger)
|
||||||
|
implementation(projects.libraries.matrix.api)
|
||||||
|
implementation(projects.libraries.core)
|
||||||
|
implementation(projects.libraries.di)
|
||||||
|
|
||||||
|
implementation(libs.androidx.annotationjvm)
|
||||||
|
implementation(libs.coroutines.core)
|
||||||
|
|
||||||
|
testImplementation(projects.tests.testutils)
|
||||||
|
testImplementation(libs.test.junit)
|
||||||
|
testImplementation(libs.test.truth)
|
||||||
|
testImplementation(libs.test.mockk)
|
||||||
|
testImplementation(libs.test.turbine)
|
||||||
|
testImplementation(libs.coroutines.core)
|
||||||
|
testImplementation(libs.coroutines.test)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.voicerecorder.impl
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import androidx.annotation.RequiresPermission
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
|
import io.element.android.libraries.core.coroutine.childScope
|
||||||
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import io.element.android.libraries.di.SingleIn
|
||||||
|
import io.element.android.libraries.voicerecorder.api.VoiceRecorder
|
||||||
|
import io.element.android.libraries.voicerecorder.api.VoiceRecorderState
|
||||||
|
import io.element.android.libraries.voicerecorder.impl.audio.Audio
|
||||||
|
import io.element.android.libraries.voicerecorder.impl.audio.AudioConfig
|
||||||
|
import io.element.android.libraries.voicerecorder.impl.audio.AudioLevelCalculator
|
||||||
|
import io.element.android.libraries.voicerecorder.impl.audio.AudioReader
|
||||||
|
import io.element.android.libraries.voicerecorder.impl.audio.Encoder
|
||||||
|
import io.element.android.libraries.voicerecorder.impl.file.VoiceFileConfig
|
||||||
|
import io.element.android.libraries.voicerecorder.impl.file.VoiceFileManager
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.io.File
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@SingleIn(RoomScope::class)
|
||||||
|
@ContributesBinding(RoomScope::class)
|
||||||
|
class VoiceRecorderImpl @Inject constructor(
|
||||||
|
private val dispatchers: CoroutineDispatchers,
|
||||||
|
private val audioReaderFactory: AudioReader.Factory,
|
||||||
|
private val encoder: Encoder,
|
||||||
|
private val fileManager: VoiceFileManager,
|
||||||
|
private val config: AudioConfig,
|
||||||
|
private val fileConfig: VoiceFileConfig,
|
||||||
|
private val audioLevelCalculator: AudioLevelCalculator,
|
||||||
|
appCoroutineScope: CoroutineScope,
|
||||||
|
) : VoiceRecorder {
|
||||||
|
private val voiceCoroutineScope by lazy {
|
||||||
|
appCoroutineScope.childScope(dispatchers.io, "VoiceRecorder-${UUID.randomUUID()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var outputFile: File? = null
|
||||||
|
private var audioReader: AudioReader? = null
|
||||||
|
private var recordingJob: Job? = null
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<VoiceRecorderState>(VoiceRecorderState.Idle)
|
||||||
|
override val state: StateFlow<VoiceRecorderState> = _state
|
||||||
|
|
||||||
|
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
|
||||||
|
override suspend fun startRecord() {
|
||||||
|
Timber.i("Voice recorder started recording")
|
||||||
|
outputFile = fileManager.createFile()
|
||||||
|
.also(encoder::init)
|
||||||
|
|
||||||
|
val audioRecorder = audioReaderFactory.create(config, dispatchers).also { audioReader = it }
|
||||||
|
|
||||||
|
recordingJob = voiceCoroutineScope.launch {
|
||||||
|
audioRecorder.record { audio ->
|
||||||
|
when (audio) {
|
||||||
|
is Audio.Data -> {
|
||||||
|
val audioLevel = audioLevelCalculator.calculateAudioLevel(audio.buffer)
|
||||||
|
_state.emit(VoiceRecorderState.Recording(audioLevel))
|
||||||
|
encoder.encode(audio.buffer, audio.readSize)
|
||||||
|
}
|
||||||
|
is Audio.Error -> {
|
||||||
|
Timber.e("Voice message error: code=${audio.audioRecordErrorCode}")
|
||||||
|
_state.emit(VoiceRecorderState.Recording(0.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the current recording.
|
||||||
|
*
|
||||||
|
* Call [deleteRecording] to delete any recorded audio.
|
||||||
|
*/
|
||||||
|
override suspend fun stopRecord(
|
||||||
|
cancelled: Boolean
|
||||||
|
) {
|
||||||
|
recordingJob?.cancel()?.also {
|
||||||
|
Timber.i("Voice recorder stopped recording")
|
||||||
|
}
|
||||||
|
recordingJob = null
|
||||||
|
|
||||||
|
audioReader?.stop()
|
||||||
|
audioReader = null
|
||||||
|
encoder.release()
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
deleteRecording()
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.emit(
|
||||||
|
when (val file = outputFile) {
|
||||||
|
null -> VoiceRecorderState.Idle
|
||||||
|
else -> VoiceRecorderState.Finished(file, fileConfig.mimeType)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the current recording and delete the output file.
|
||||||
|
*/
|
||||||
|
override suspend fun deleteRecording() {
|
||||||
|
outputFile?.let(fileManager::deleteFile)?.also {
|
||||||
|
Timber.i("Voice recorder deleted recording")
|
||||||
|
}
|
||||||
|
outputFile = null
|
||||||
|
_state.emit(VoiceRecorderState.Idle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.voicerecorder.impl.audio
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.media.AudioRecord
|
||||||
|
import android.media.audiofx.AutomaticGainControl
|
||||||
|
import android.media.audiofx.NoiseSuppressor
|
||||||
|
import androidx.annotation.RequiresPermission
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
|
import io.element.android.libraries.core.data.tryOrNull
|
||||||
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class AndroidAudioReader
|
||||||
|
@RequiresPermission(Manifest.permission.RECORD_AUDIO) private constructor(
|
||||||
|
private val config: AudioConfig,
|
||||||
|
private val dispatchers: CoroutineDispatchers,
|
||||||
|
) : AudioReader {
|
||||||
|
private val audioRecord: AudioRecord
|
||||||
|
private var noiseSuppressor: NoiseSuppressor? = null
|
||||||
|
private var automaticGainControl: AutomaticGainControl? = null
|
||||||
|
private val outputBuffer: ShortArray
|
||||||
|
|
||||||
|
init {
|
||||||
|
outputBuffer = createOutputBuffer(config.sampleRate)
|
||||||
|
audioRecord = AudioRecord.Builder().setAudioSource(config.source).setAudioFormat(config.format).setBufferSizeInBytes(outputBuffer.sizeInBytes()).build()
|
||||||
|
noiseSuppressor = requestNoiseSuppressor(audioRecord)
|
||||||
|
automaticGainControl = requestAutomaticGainControl(audioRecord)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record audio data continuously.
|
||||||
|
*
|
||||||
|
* @param onAudio callback when audio is read.
|
||||||
|
*/
|
||||||
|
override suspend fun record(
|
||||||
|
onAudio: suspend (Audio) -> Unit,
|
||||||
|
) {
|
||||||
|
audioRecord.startRecording()
|
||||||
|
withContext(dispatchers.io) {
|
||||||
|
while (isActive) {
|
||||||
|
if (audioRecord.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
onAudio(read())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun read(): Audio {
|
||||||
|
val result = audioRecord.read(outputBuffer, 0, outputBuffer.size)
|
||||||
|
|
||||||
|
if (isAudioRecordErrorResult(result)) {
|
||||||
|
return Audio.Error(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Audio.Data(
|
||||||
|
result,
|
||||||
|
outputBuffer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stop() {
|
||||||
|
if (audioRecord.state == AudioRecord.STATE_INITIALIZED) {
|
||||||
|
audioRecord.stop()
|
||||||
|
}
|
||||||
|
audioRecord.release()
|
||||||
|
|
||||||
|
noiseSuppressor?.release()
|
||||||
|
noiseSuppressor = null
|
||||||
|
|
||||||
|
automaticGainControl?.release()
|
||||||
|
automaticGainControl = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createOutputBuffer(sampleRate: SampleRate): ShortArray {
|
||||||
|
val bufferSizeInShorts = AudioRecord.getMinBufferSize(
|
||||||
|
sampleRate.hz,
|
||||||
|
config.format.channelMask,
|
||||||
|
config.format.encoding
|
||||||
|
)
|
||||||
|
return ShortArray(bufferSizeInShorts)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestNoiseSuppressor(audioRecord: AudioRecord): NoiseSuppressor? {
|
||||||
|
if (!NoiseSuppressor.isAvailable()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return tryOrNull {
|
||||||
|
NoiseSuppressor.create(audioRecord.audioSessionId).apply {
|
||||||
|
enabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestAutomaticGainControl(audioRecord: AudioRecord): AutomaticGainControl? {
|
||||||
|
if (!AutomaticGainControl.isAvailable()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return tryOrNull {
|
||||||
|
AutomaticGainControl.create(audioRecord.audioSessionId).apply {
|
||||||
|
enabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ContributesBinding(RoomScope::class)
|
||||||
|
companion object Factory : AudioReader.Factory {
|
||||||
|
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
|
||||||
|
override fun create(config: AudioConfig, dispatchers: CoroutineDispatchers): AndroidAudioReader {
|
||||||
|
return AndroidAudioReader(config, dispatchers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isAudioRecordErrorResult(result: Int): Boolean {
|
||||||
|
return result < 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ShortArray.sizeInBytes(): Int = size * Short.SIZE_BYTES
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.voicerecorder.impl.audio
|
||||||
|
|
||||||
|
sealed class Audio {
|
||||||
|
class Data(
|
||||||
|
val readSize: Int,
|
||||||
|
val buffer: ShortArray,
|
||||||
|
) : Audio()
|
||||||
|
|
||||||
|
data class Error(
|
||||||
|
val audioRecordErrorCode: Int
|
||||||
|
) : Audio()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.voicerecorder.impl.audio
|
||||||
|
|
||||||
|
import android.media.AudioFormat
|
||||||
|
import android.media.MediaRecorder.AudioSource
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audio configuration for voice recording.
|
||||||
|
*
|
||||||
|
* @property source the audio source to use, see constants in [AudioSource]
|
||||||
|
* @property format the audio format to use, see [AudioFormat]
|
||||||
|
* @property sampleRate the sample rate to use. Ensure this matches the value set in [format].
|
||||||
|
* @property bitRate the bitrate in bps
|
||||||
|
*/
|
||||||
|
data class AudioConfig(
|
||||||
|
val source: Int,
|
||||||
|
val format: AudioFormat,
|
||||||
|
val sampleRate: SampleRate,
|
||||||
|
val bitRate: Int,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.voicerecorder.impl.audio
|
||||||
|
|
||||||
|
interface AudioLevelCalculator {
|
||||||
|
/**
|
||||||
|
* Calculate the audio level of the audio buffer.
|
||||||
|
*
|
||||||
|
* @param buffer The audio buffer containing raw audio data.
|
||||||
|
*
|
||||||
|
* @return A value between 0 and 1.
|
||||||
|
*/
|
||||||
|
fun calculateAudioLevel(buffer: ShortArray): Double
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.voicerecorder.impl.audio
|
||||||
|
|
||||||
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
|
|
||||||
|
interface AudioReader {
|
||||||
|
/**
|
||||||
|
* Record audio data continuously.
|
||||||
|
*
|
||||||
|
* @param onAudio callback when audio is read.
|
||||||
|
*/
|
||||||
|
suspend fun record(
|
||||||
|
onAudio: suspend (Audio) -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun stop()
|
||||||
|
|
||||||
|
interface Factory {
|
||||||
|
fun create(config: AudioConfig, dispatchers: CoroutineDispatchers): AudioReader
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.voicerecorder.impl.audio
|
||||||
|
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.math.log10
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
@ContributesBinding(RoomScope::class)
|
||||||
|
class DecibelAudioLevelCalculator @Inject constructor() : AudioLevelCalculator {
|
||||||
|
companion object {
|
||||||
|
private const val REFERENCE_DB = 50.0 // Reference dB for normal conversation
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun calculateAudioLevel(buffer: ShortArray): Double {
|
||||||
|
val rms = buffer.rootMeanSquare()
|
||||||
|
|
||||||
|
// Convert to decibels and clip
|
||||||
|
val db = 20 * log10(rms / REFERENCE_DB)
|
||||||
|
val clipped = min(db, REFERENCE_DB)
|
||||||
|
|
||||||
|
// Scale to the range [0.0, 1.0]
|
||||||
|
return clipped / REFERENCE_DB
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ShortArray.rootMeanSquare(): Double {
|
||||||
|
// Use Double to avoid overflow
|
||||||
|
val sumOfSquares: Double = sumOf { it.toDouble() * it.toDouble() }
|
||||||
|
val avgSquare = sumOfSquares / size.toDouble()
|
||||||
|
return sqrt(avgSquare)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.voicerecorder.impl.audio
|
||||||
|
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import io.element.android.opusencoder.OggOpusEncoder
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Provider
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safe wrapper for OggOpusEncoder.
|
||||||
|
*/
|
||||||
|
@ContributesBinding(RoomScope::class)
|
||||||
|
class DefaultEncoder @Inject constructor(
|
||||||
|
private val encoderProvider: Provider<OggOpusEncoder>,
|
||||||
|
config: AudioConfig,
|
||||||
|
) : Encoder {
|
||||||
|
private val bitRate = config.bitRate
|
||||||
|
private val sampleRate = config.sampleRate.asEncoderModel()
|
||||||
|
|
||||||
|
private var encoder: OggOpusEncoder? = null
|
||||||
|
override fun init(
|
||||||
|
file: File,
|
||||||
|
) {
|
||||||
|
encoder?.release()
|
||||||
|
encoder = encoderProvider.get().apply {
|
||||||
|
init(file.absolutePath, sampleRate)
|
||||||
|
setBitrate(bitRate)
|
||||||
|
// TODO check encoder application: 2048 (voice, default is typically 2049 as audio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun encode(
|
||||||
|
buffer: ShortArray,
|
||||||
|
readSize: Int,
|
||||||
|
) {
|
||||||
|
encoder?.encode(buffer, readSize)
|
||||||
|
?: Timber.w("Can't encode when encoder not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun release() {
|
||||||
|
encoder?.release()
|
||||||
|
?: Timber.w("Can't release encoder that is not initialized")
|
||||||
|
encoder = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.voicerecorder.impl.audio
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
interface Encoder {
|
||||||
|
|
||||||
|
fun init(file: File)
|
||||||
|
|
||||||
|
fun encode(buffer: ShortArray, readSize: Int)
|
||||||
|
|
||||||
|
fun release()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.voicerecorder.impl.audio
|
||||||
|
|
||||||
|
import io.element.android.opusencoder.configuration.SampleRate as LibOpusOggSampleRate
|
||||||
|
|
||||||
|
data object SampleRate {
|
||||||
|
const val hz = 48_000
|
||||||
|
fun asEncoderModel() = LibOpusOggSampleRate.Rate48kHz
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.voicerecorder.impl.di
|
||||||
|
|
||||||
|
import android.media.AudioFormat
|
||||||
|
import android.media.MediaRecorder
|
||||||
|
import com.squareup.anvil.annotations.ContributesTo
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import io.element.android.libraries.voicerecorder.impl.audio.AudioConfig
|
||||||
|
import io.element.android.libraries.voicerecorder.impl.audio.SampleRate
|
||||||
|
import io.element.android.libraries.voicerecorder.impl.file.VoiceFileConfig
|
||||||
|
import io.element.android.opusencoder.OggOpusEncoder
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@ContributesTo(RoomScope::class)
|
||||||
|
object VoiceRecorderModule {
|
||||||
|
@Provides
|
||||||
|
fun provideAudioConfig(): AudioConfig {
|
||||||
|
val sampleRate = SampleRate
|
||||||
|
return AudioConfig(
|
||||||
|
format = AudioFormat.Builder()
|
||||||
|
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||||
|
.setSampleRate(sampleRate.hz)
|
||||||
|
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
|
||||||
|
.build(),
|
||||||
|
bitRate = 24_000, // 24 kbps
|
||||||
|
sampleRate = sampleRate,
|
||||||
|
source = MediaRecorder.AudioSource.MIC,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideVoiceFileConfig(): VoiceFileConfig =
|
||||||
|
VoiceFileConfig(
|
||||||
|
cacheSubdir = "voice_recordings",
|
||||||
|
fileExt = "ogg",
|
||||||
|
mimeType = "audio/ogg",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideOggOpusEncoder(): OggOpusEncoder = OggOpusEncoder.create()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.voicerecorder.impl.file
|
||||||
|
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.libraries.core.hash.md5
|
||||||
|
import io.element.android.libraries.di.CacheDirectory
|
||||||
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
|
import java.io.File
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@ContributesBinding(RoomScope::class)
|
||||||
|
class DefaultVoiceFileManager @Inject constructor(
|
||||||
|
@CacheDirectory private val cacheDir: File,
|
||||||
|
private val config: VoiceFileConfig,
|
||||||
|
room: MatrixRoom,
|
||||||
|
) : VoiceFileManager {
|
||||||
|
|
||||||
|
private val roomId: RoomId = room.roomId
|
||||||
|
|
||||||
|
override fun createFile(): File {
|
||||||
|
val fileName = "${UUID.randomUUID()}.${config.fileExt}"
|
||||||
|
val outputDirectory = File(cacheDir, config.cacheSubdir)
|
||||||
|
val roomDir = File(outputDirectory, roomId.value.md5())
|
||||||
|
.apply(File::mkdirs)
|
||||||
|
return File(roomDir, fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deleteFile(file: File) {
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.voicerecorder.impl.file
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File configuration for voice recording.
|
||||||
|
*
|
||||||
|
* @property cacheSubdir the subdirectory in the cache dir to use.
|
||||||
|
* @property fileExt the file extension for audio files.
|
||||||
|
* @property mimeType the mime type of audio files.
|
||||||
|
*/
|
||||||
|
data class VoiceFileConfig(
|
||||||
|
val cacheSubdir: String,
|
||||||
|
val fileExt: String,
|
||||||
|
val mimeType: String,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.voicerecorder.impl.file
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
interface VoiceFileManager {
|
||||||
|
fun createFile(): File
|
||||||
|
|
||||||
|
fun deleteFile(file: File)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.voicerecorder.impl
|
||||||
|
|
||||||
|
import android.media.AudioFormat
|
||||||
|
import android.media.MediaRecorder
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.voicerecorder.api.VoiceRecorderState
|
||||||
|
import io.element.android.libraries.voicerecorder.impl.audio.Audio
|
||||||
|
import io.element.android.libraries.voicerecorder.impl.audio.AudioConfig
|
||||||
|
import io.element.android.libraries.voicerecorder.impl.audio.SampleRate
|
||||||
|
import io.element.android.libraries.voicerecorder.impl.di.VoiceRecorderModule
|
||||||
|
import io.element.android.libraries.voicerecorder.test.FakeAudioLevelCalculator
|
||||||
|
import io.element.android.libraries.voicerecorder.test.FakeAudioRecorderFactory
|
||||||
|
import io.element.android.libraries.voicerecorder.test.FakeEncoder
|
||||||
|
import io.element.android.libraries.voicerecorder.test.FakeFileSystem
|
||||||
|
import io.element.android.libraries.voicerecorder.test.FakeVoiceFileManager
|
||||||
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.BeforeClass
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class VoiceRecorderImplTest {
|
||||||
|
private val fakeFileSystem = FakeFileSystem()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `it emits the initial state`() = runTest {
|
||||||
|
val voiceRecorder = createVoiceRecorder()
|
||||||
|
voiceRecorder.state.test {
|
||||||
|
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when recording, it emits the recording state`() = runTest {
|
||||||
|
val voiceRecorder = createVoiceRecorder()
|
||||||
|
voiceRecorder.state.test {
|
||||||
|
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle)
|
||||||
|
|
||||||
|
voiceRecorder.startRecord()
|
||||||
|
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(1.0))
|
||||||
|
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(0.0))
|
||||||
|
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(1.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when stopped, it provides a file`() = runTest {
|
||||||
|
val voiceRecorder = createVoiceRecorder()
|
||||||
|
voiceRecorder.state.test {
|
||||||
|
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle)
|
||||||
|
|
||||||
|
voiceRecorder.startRecord()
|
||||||
|
skipItems(3)
|
||||||
|
voiceRecorder.stopRecord()
|
||||||
|
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Finished(File(FILE_PATH), "audio/ogg"))
|
||||||
|
assertThat(fakeFileSystem.files[File(FILE_PATH)]).isEqualTo(ENCODED_DATA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when cancelled, it deletes the file`() = runTest {
|
||||||
|
val voiceRecorder = createVoiceRecorder()
|
||||||
|
voiceRecorder.state.test {
|
||||||
|
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle)
|
||||||
|
|
||||||
|
voiceRecorder.startRecord()
|
||||||
|
skipItems(3)
|
||||||
|
voiceRecorder.stopRecord(cancelled = true)
|
||||||
|
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle)
|
||||||
|
assertThat(fakeFileSystem.files[File(FILE_PATH)]).isNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TestScope.createVoiceRecorder(): VoiceRecorderImpl {
|
||||||
|
val fileConfig = VoiceRecorderModule.provideVoiceFileConfig()
|
||||||
|
return VoiceRecorderImpl(
|
||||||
|
dispatchers = testCoroutineDispatchers(),
|
||||||
|
audioReaderFactory = FakeAudioRecorderFactory(
|
||||||
|
audio = AUDIO,
|
||||||
|
),
|
||||||
|
encoder = FakeEncoder(fakeFileSystem),
|
||||||
|
config = AudioConfig(
|
||||||
|
format = AUDIO_FORMAT,
|
||||||
|
bitRate = 24_000, // 24 kbps
|
||||||
|
sampleRate = SampleRate,
|
||||||
|
source = MediaRecorder.AudioSource.MIC,
|
||||||
|
),
|
||||||
|
fileConfig = fileConfig,
|
||||||
|
fileManager = FakeVoiceFileManager(fakeFileSystem, fileConfig, FILE_ID),
|
||||||
|
audioLevelCalculator = FakeAudioLevelCalculator(),
|
||||||
|
appCoroutineScope = backgroundScope,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val FILE_ID: String = "recording"
|
||||||
|
const val FILE_PATH = "voice_recordings/${FILE_ID}.ogg"
|
||||||
|
private lateinit var AUDIO_FORMAT: AudioFormat
|
||||||
|
|
||||||
|
// FakeEncoder doesn't actually encode, it just writes the data to the file
|
||||||
|
private const val ENCODED_DATA = "[32767, 32767, 32767][32767, 32767, 32767]"
|
||||||
|
private const val MAX_AMP = Short.MAX_VALUE
|
||||||
|
private val AUDIO = listOf(
|
||||||
|
Audio.Data(3, shortArrayOf(MAX_AMP, MAX_AMP, MAX_AMP)),
|
||||||
|
Audio.Error(-1),
|
||||||
|
Audio.Data(3, shortArrayOf(MAX_AMP, MAX_AMP, MAX_AMP)),
|
||||||
|
)
|
||||||
|
|
||||||
|
@BeforeClass
|
||||||
|
@JvmStatic
|
||||||
|
fun initAudioFormat() {
|
||||||
|
AUDIO_FORMAT = mockk()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.voicerecorder.impl.audio
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class DecibelAudioLevelCalculatorTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `given max values, it returns values within range`() {
|
||||||
|
val calculator = DecibelAudioLevelCalculator()
|
||||||
|
val buffer = ShortArray(100) { Short.MAX_VALUE }
|
||||||
|
val level = calculator.calculateAudioLevel(buffer)
|
||||||
|
assert(level in 0.0..1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `given mixed values, it returns values within range`() {
|
||||||
|
val calculator = DecibelAudioLevelCalculator()
|
||||||
|
val buffer = shortArrayOf(Short.MAX_VALUE, Short.MIN_VALUE, -1, 1)
|
||||||
|
val level = calculator.calculateAudioLevel(buffer)
|
||||||
|
assert(level in 0.0..1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `given min values, it returns values within range`() {
|
||||||
|
val calculator = DecibelAudioLevelCalculator()
|
||||||
|
val buffer = ShortArray(100) { Short.MIN_VALUE }
|
||||||
|
val level = calculator.calculateAudioLevel(buffer)
|
||||||
|
assert(level in 0.0..1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.voicerecorder.test
|
||||||
|
|
||||||
|
import io.element.android.libraries.voicerecorder.impl.audio.AudioLevelCalculator
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
class FakeAudioLevelCalculator: AudioLevelCalculator {
|
||||||
|
override fun calculateAudioLevel(buffer: ShortArray): Double {
|
||||||
|
return buffer.map { abs(it.toDouble()) }.average() / Short.MAX_VALUE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.voicerecorder.test
|
||||||
|
|
||||||
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
|
import io.element.android.libraries.voicerecorder.impl.audio.Audio
|
||||||
|
import io.element.android.libraries.voicerecorder.impl.audio.AudioReader
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.yield
|
||||||
|
|
||||||
|
class FakeAudioReader(
|
||||||
|
private val dispatchers: CoroutineDispatchers,
|
||||||
|
private val audio: List<Audio>,
|
||||||
|
) : AudioReader {
|
||||||
|
private var isRecording = false
|
||||||
|
override suspend fun record(onAudio: suspend (Audio) -> Unit) {
|
||||||
|
isRecording = true
|
||||||
|
withContext(dispatchers.io) {
|
||||||
|
val audios = audio.iterator()
|
||||||
|
while (audios.hasNext()) {
|
||||||
|
if (!isRecording) break
|
||||||
|
onAudio(audios.next())
|
||||||
|
}
|
||||||
|
while (isActive) {
|
||||||
|
// do not return from the coroutine until it is cancelled
|
||||||
|
yield()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stop() {
|
||||||
|
isRecording = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.voicerecorder.test
|
||||||
|
|
||||||
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
|
import io.element.android.libraries.voicerecorder.impl.audio.Audio
|
||||||
|
import io.element.android.libraries.voicerecorder.impl.audio.AudioConfig
|
||||||
|
import io.element.android.libraries.voicerecorder.impl.audio.AudioReader
|
||||||
|
|
||||||
|
class FakeAudioRecorderFactory(
|
||||||
|
private val audio: List<Audio>
|
||||||
|
): AudioReader.Factory {
|
||||||
|
override fun create(config: AudioConfig, dispatchers: CoroutineDispatchers): AudioReader {
|
||||||
|
return FakeAudioReader(dispatchers, audio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.voicerecorder.test
|
||||||
|
|
||||||
|
import io.element.android.libraries.voicerecorder.impl.audio.Encoder
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class FakeEncoder(
|
||||||
|
private val fakeFileSystem: FakeFileSystem
|
||||||
|
) : Encoder {
|
||||||
|
private var curFile: File? = null
|
||||||
|
override fun init(file: File) {
|
||||||
|
curFile = file
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun encode(buffer: ShortArray, readSize: Int) {
|
||||||
|
val file = curFile
|
||||||
|
?: error("Encoder not initialized")
|
||||||
|
|
||||||
|
fakeFileSystem.appendToFile(file, buffer, readSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun release() {
|
||||||
|
curFile = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.voicerecorder.test
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class FakeFileSystem {
|
||||||
|
// Map of file to file content
|
||||||
|
val files = mutableMapOf<File, String>()
|
||||||
|
|
||||||
|
fun createFile(file: File) {
|
||||||
|
if(files.containsKey(file)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
files[file] = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
fun appendToFile(file: File, buffer: ShortArray, readSize: Int) {
|
||||||
|
val content = files[file]
|
||||||
|
?: error("File ${file.path} does not exist")
|
||||||
|
|
||||||
|
files[file] = content + buffer.sliceArray(0 until readSize).contentToString()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteFile(file: File) {
|
||||||
|
files.remove(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.voicerecorder.test
|
||||||
|
|
||||||
|
import io.element.android.libraries.voicerecorder.impl.file.VoiceFileConfig
|
||||||
|
import io.element.android.libraries.voicerecorder.impl.file.VoiceFileManager
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class FakeVoiceFileManager(
|
||||||
|
private val fakeFileSystem: FakeFileSystem,
|
||||||
|
private val config: VoiceFileConfig,
|
||||||
|
private val fileId: String,
|
||||||
|
) : VoiceFileManager {
|
||||||
|
override fun createFile(): File {
|
||||||
|
val file = File("${config.cacheSubdir}/$fileId.${config.fileExt}")
|
||||||
|
fakeFileSystem.createFile(file)
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deleteFile(file: File) {
|
||||||
|
fakeFileSystem.deleteFile(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
30
libraries/voicerecorder/test/build.gradle.kts
Normal file
30
libraries/voicerecorder/test/build.gradle.kts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("io.element.android-library")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "io.element.android.libraries.voicerecorder.test"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
api(projects.libraries.voicerecorder.api)
|
||||||
|
implementation(projects.tests.testutils)
|
||||||
|
|
||||||
|
implementation(libs.coroutines.test)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.voicerecorder.test
|
||||||
|
|
||||||
|
import io.element.android.libraries.voicerecorder.api.VoiceRecorder
|
||||||
|
import io.element.android.libraries.voicerecorder.api.VoiceRecorderState
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class FakeVoiceRecorder(
|
||||||
|
private val levels: List<Double> = listOf(0.1, 0.2)
|
||||||
|
) : VoiceRecorder {
|
||||||
|
private val _state = MutableStateFlow<VoiceRecorderState>(VoiceRecorderState.Idle)
|
||||||
|
override val state: StateFlow<VoiceRecorderState> = _state
|
||||||
|
|
||||||
|
private var curRecording: File? = null
|
||||||
|
|
||||||
|
private var securityException: SecurityException? = null
|
||||||
|
|
||||||
|
override suspend fun startRecord() {
|
||||||
|
securityException?.let { throw it }
|
||||||
|
|
||||||
|
if (curRecording != null) {
|
||||||
|
error("Previous recording was not cleared")
|
||||||
|
}
|
||||||
|
curRecording = File("file.ogg")
|
||||||
|
|
||||||
|
levels.forEach {
|
||||||
|
_state.emit(VoiceRecorderState.Recording(it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun stopRecord(
|
||||||
|
cancelled: Boolean
|
||||||
|
) {
|
||||||
|
if (cancelled) {
|
||||||
|
deleteRecording()
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.emit(
|
||||||
|
when (curRecording) {
|
||||||
|
null -> VoiceRecorderState.Idle
|
||||||
|
else -> VoiceRecorderState.Finished(curRecording!!, "audio/ogg")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteRecording() {
|
||||||
|
curRecording = null
|
||||||
|
|
||||||
|
_state.emit(
|
||||||
|
VoiceRecorderState.Idle
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun givenThrowsSecurityException(exception: SecurityException) {
|
||||||
|
this.securityException = exception
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -101,6 +101,7 @@ fun DependencyHandlerScope.allLibrariesImpl() {
|
||||||
implementation(project(":libraries:mediaupload:impl"))
|
implementation(project(":libraries:mediaupload:impl"))
|
||||||
implementation(project(":libraries:usersearch:impl"))
|
implementation(project(":libraries:usersearch:impl"))
|
||||||
implementation(project(":libraries:textcomposer:impl"))
|
implementation(project(":libraries:textcomposer:impl"))
|
||||||
|
implementation(project(":libraries:voicerecorder:impl"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun DependencyHandlerScope.allServicesImpl() {
|
fun DependencyHandlerScope.allServicesImpl() {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:96999517a90c46cc256a1703eb0919adc81682478344e8dab922f39ebb000530
|
oid sha256:34c05059f7c4f997f3c4af339f548773aafbbcb688d563d559c14c75fba1c70d
|
||||||
size 8420
|
size 9039
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:a7be984402697ade0648a7c7cf6785f6f22ac3f3fbb44bc3e1a71be73400461f
|
oid sha256:dd5d861bd3630a0341e6f8ef9bf16eb4135ed770bf549d4713cedb476c974cb2
|
||||||
size 8057
|
size 8634
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:47e727f1ff283a022aa174266b83c837aa60b379c0c410e94d9ff6b792f2aead
|
|
||||||
size 7390
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:16ac898bc3ca7c827f52612fb5f2c6051de4f746a1328dacaf6fba1cefa6f896
|
|
||||||
size 7053
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:1ae279216869b9a1a9fc8dd73ab9c5a6f769b3f275a8cfafddc51ab009868089
|
||||||
|
size 7829
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:646d5522a31967921f2b1fbbf5716c8bd47d8c312e7778772e92618102526bc8
|
||||||
|
size 7521
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:2a06a32b3b576c01074eb14f236f8043d209600fb098fdd3933b01f99055c24d
|
||||||
|
size 8069
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:cd347e9e28f7518bd4e4169426d9172ef7a107895d9fb93744daf47bf1ae0e50
|
||||||
|
size 7681
|
||||||
Loading…
Add table
Add a link
Reference in a new issue