Record and send voice messages (#1596)
--------- Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
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.permissions.api)
|
||||
implementation(projects.libraries.preferences.api)
|
||||
implementation(projects.libraries.voicerecorder.api)
|
||||
implementation(projects.features.networkmonitor.api)
|
||||
implementation(projects.services.analytics.api)
|
||||
implementation(libs.coil.compose)
|
||||
@@ -80,6 +81,7 @@ dependencies {
|
||||
testImplementation(projects.libraries.permissions.test)
|
||||
testImplementation(projects.libraries.preferences.test)
|
||||
testImplementation(projects.libraries.textcomposer.test)
|
||||
testImplementation(projects.libraries.voicerecorder.test)
|
||||
testImplementation(libs.test.mockk)
|
||||
|
||||
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.Presenter
|
||||
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.AvatarSize
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
@@ -101,6 +102,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||
private val preferencesStore: PreferencesStore,
|
||||
private val featureFlagsService: FeatureFlagService,
|
||||
@Assisted private val navigator: MessagesNavigator,
|
||||
private val buildMeta: BuildMeta,
|
||||
) : Presenter<MessagesState> {
|
||||
|
||||
@AssistedFactory
|
||||
@@ -203,6 +205,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||
enableTextFormatting = enableTextFormatting,
|
||||
enableVoiceMessages = enableVoiceMessages,
|
||||
enableInRoomCalls = enableInRoomCalls,
|
||||
appName = buildMeta.applicationName,
|
||||
eventSink = { handleEvents(it) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,5 +50,6 @@ data class MessagesState(
|
||||
val enableTextFormatting: Boolean,
|
||||
val enableVoiceMessages: Boolean,
|
||||
val enableInRoomCalls: Boolean,
|
||||
val appName: String,
|
||||
val eventSink: (MessagesEvents) -> Unit
|
||||
)
|
||||
|
||||
@@ -86,5 +86,6 @@ fun aMessagesState() = MessagesState(
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
enableInRoomCalls = true,
|
||||
appName = "Element",
|
||||
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.RetrySendMessageMenu
|
||||
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.libraries.androidutils.ui.hideKeyboard
|
||||
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.utils.CommonDrawables
|
||||
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.rememberSnackbarHostState
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
@@ -107,6 +110,10 @@ fun MessagesView(
|
||||
) {
|
||||
LogCompositions(tag = "MessagesScreen", msg = "Root")
|
||||
|
||||
OnLifecycleEvent { _, event ->
|
||||
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.LifecycleEvent(event))
|
||||
}
|
||||
|
||||
AttachmentStateView(
|
||||
state = state.composerState.attachmentsState,
|
||||
onPreviewAttachments = onPreviewAttachments,
|
||||
@@ -306,6 +313,18 @@ private fun MessagesViewContent(
|
||||
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(
|
||||
sheetDragHandle = if (state.composerState.showTextFormatting) {
|
||||
@Composable { BottomSheetDragHandle() }
|
||||
|
||||
@@ -71,10 +71,14 @@ internal fun MessageComposerView(
|
||||
}
|
||||
}
|
||||
|
||||
fun onVoiceRecordButtonEvent(press: PressEvent) {
|
||||
val onVoiceRecordButtonEvent = { press: PressEvent ->
|
||||
voiceMessageState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(press))
|
||||
}
|
||||
|
||||
fun onSendVoiceMessage() {
|
||||
voiceMessageState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||
}
|
||||
|
||||
TextComposer(
|
||||
modifier = modifier,
|
||||
state = state.richTextEditorState,
|
||||
@@ -89,7 +93,8 @@ internal fun MessageComposerView(
|
||||
onDismissTextFormatting = ::onDismissTextFormatting,
|
||||
enableTextFormatting = enableTextFormatting,
|
||||
enableVoiceMessages = enableVoiceMessages,
|
||||
onVoiceRecordButtonEvent = ::onVoiceRecordButtonEvent,
|
||||
onVoiceRecordButtonEvent = onVoiceRecordButtonEvent,
|
||||
onSendVoiceMessage = ::onSendVoiceMessage,
|
||||
onError = ::onError,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,10 +16,15 @@
|
||||
|
||||
package io.element.android.features.messages.impl.voicemessages
|
||||
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import io.element.android.libraries.textcomposer.model.PressEvent
|
||||
|
||||
sealed interface VoiceMessageComposerEvents {
|
||||
data class RecordButtonEvent(
|
||||
val pressEvent: PressEvent
|
||||
): 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
|
||||
|
||||
import android.Manifest
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.di.RoomScope
|
||||
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.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
|
||||
|
||||
@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
|
||||
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) {
|
||||
PressEvent.PressStart -> {
|
||||
// TODO start the recording
|
||||
voiceMessageState = VoiceMessageState.Recording
|
||||
}
|
||||
PressEvent.LongPressEnd -> {
|
||||
// TODO finish the recording
|
||||
voiceMessageState = VoiceMessageState.Idle
|
||||
}
|
||||
PressEvent.Tapped -> {
|
||||
// TODO discard the recording and show the 'hold to record' tooltip
|
||||
voiceMessageState = VoiceMessageState.Idle
|
||||
val permissionState = permissionsPresenter.present()
|
||||
var isSending by remember { mutableStateOf(false) }
|
||||
|
||||
val onLifecycleEvent = { event: Lifecycle.Event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_PAUSE -> {
|
||||
appCoroutineScope.finishRecording()
|
||||
}
|
||||
Lifecycle.Event.ON_DESTROY -> {
|
||||
appCoroutineScope.cancelRecording()
|
||||
}
|
||||
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) {
|
||||
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(
|
||||
voiceMessageState = voiceMessageState,
|
||||
eventSink = { handleEvents(it) }
|
||||
voiceMessageState = when (val state = recorderState) {
|
||||
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
|
||||
data class VoiceMessageComposerState(
|
||||
val voiceMessageState: VoiceMessageState,
|
||||
val showPermissionRationaleDialog: Boolean,
|
||||
val eventSink: (VoiceMessageComposerEvents) -> Unit,
|
||||
)
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||
internal open class VoiceMessageComposerStateProvider : PreviewParameterProvider<VoiceMessageComposerState> {
|
||||
override val values: Sequence<VoiceMessageComposerState>
|
||||
get() = sequenceOf(
|
||||
aVoiceMessageComposerState(voiceMessageState = VoiceMessageState.Recording),
|
||||
aVoiceMessageComposerState(voiceMessageState = VoiceMessageState.Recording(level = 0.5)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,5 +30,6 @@ internal fun aVoiceMessageComposerState(
|
||||
voiceMessageState: VoiceMessageState = VoiceMessageState.Idle,
|
||||
) = VoiceMessageComposerState(
|
||||
voiceMessageState = voiceMessageState,
|
||||
showPermissionRationaleDialog = false,
|
||||
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_SESSION_ID
|
||||
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.aRoomMember
|
||||
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.FakePermissionsPresenterFactory
|
||||
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.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||
@@ -607,20 +609,28 @@ class MessagesPresenterTest {
|
||||
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
|
||||
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
||||
): MessagesPresenter {
|
||||
val mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom)
|
||||
val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter)
|
||||
val messageComposerPresenter = MessageComposerPresenter(
|
||||
appCoroutineScope = this,
|
||||
room = matrixRoom,
|
||||
mediaPickerProvider = FakePickerProvider(),
|
||||
featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.NotificationSettings.key to true)),
|
||||
localMediaFactory = FakeLocalMediaFactory(mockMediaUrl),
|
||||
mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom),
|
||||
mediaSender = mediaSender,
|
||||
snackbarDispatcher = SnackbarDispatcher(),
|
||||
analyticsService = analyticsService,
|
||||
messageComposerContext = MessageComposerContextImpl(),
|
||||
richTextEditorStateFactory = TestRichTextEditorStateFactory(),
|
||||
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
|
||||
permissionsPresenterFactory = permissionsPresenterFactory,
|
||||
)
|
||||
val voiceMessageComposerPresenter = VoiceMessageComposerPresenter(
|
||||
this,
|
||||
FakeVoiceRecorder(),
|
||||
analyticsService,
|
||||
mediaSender,
|
||||
permissionsPresenterFactory,
|
||||
)
|
||||
val voiceMessageComposerPresenter = VoiceMessageComposerPresenter()
|
||||
val timelinePresenter = TimelinePresenter(
|
||||
timelineItemsFactory = aTimelineItemsFactory(),
|
||||
room = matrixRoom,
|
||||
@@ -649,6 +659,7 @@ class MessagesPresenterTest {
|
||||
clipboardHelper = clipboardHelper,
|
||||
preferencesStore = preferencesStore,
|
||||
featureFlagsService = FakeFeatureFlagService(),
|
||||
buildMeta = aBuildMeta(),
|
||||
dispatchers = coroutineDispatchers,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,16 +18,31 @@
|
||||
|
||||
package io.element.android.features.messages.voicemessages
|
||||
|
||||
import android.Manifest
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.TurbineTestContext
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
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.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.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 kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -37,53 +52,349 @@ class VoiceMessageComposerPresenterTest {
|
||||
@get:Rule
|
||||
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
|
||||
fun `present - initial state`() = runTest {
|
||||
val presenter = createPresenter()
|
||||
val presenter = createVoiceMessageComposerPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
|
||||
testPauseAndDestroy(initialState)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - recording state`() = runTest {
|
||||
val presenter = createPresenter()
|
||||
val presenter = createVoiceMessageComposerPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
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
|
||||
fun `present - abort recording`() = runTest {
|
||||
val presenter = createPresenter()
|
||||
val presenter = createVoiceMessageComposerPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.Tapped))
|
||||
|
||||
assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - finish recording`() = runTest {
|
||||
val presenter = createPresenter()
|
||||
val presenter = createVoiceMessageComposerPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
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_core = { module = "androidx.core:core", 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_datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
|
||||
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_ktx = "org.maplibre.gl:android-sdk-ktx-v7:2.0.1"
|
||||
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:2.0.1"
|
||||
opusencoder = "io.element.android:opusencoder:1.1.0"
|
||||
|
||||
# Analytics
|
||||
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 ->
|
||||
room.sendMedia(info, progressCallback)
|
||||
}
|
||||
.onFailure { error ->
|
||||
val job = ongoingUploadJobs.remove(Job)
|
||||
if (error !is CancellationException) {
|
||||
job?.cancel()
|
||||
}
|
||||
}
|
||||
.onSuccess {
|
||||
ongoingUploadJobs.remove(Job)
|
||||
}
|
||||
.handleSendResult()
|
||||
}
|
||||
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(
|
||||
uploadInfo: MediaUploadInfo,
|
||||
@@ -90,7 +117,14 @@ class MediaSender @Inject constructor(
|
||||
progressCallback = progressCallback
|
||||
)
|
||||
}
|
||||
|
||||
is MediaUploadInfo.VoiceMessage -> {
|
||||
sendVoiceMessage(
|
||||
file = uploadInfo.file,
|
||||
audioInfo = uploadInfo.audioInfo,
|
||||
waveform = uploadInfo.waveform,
|
||||
progressCallback = progressCallback
|
||||
)
|
||||
}
|
||||
is MediaUploadInfo.AnyFile -> {
|
||||
sendFile(
|
||||
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 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 VoiceMessage(override val file: File, val audioInfo: AudioInfo, val waveform: List<Int>) : 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.Image -> 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
|
||||
|
||||
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.mediaupload.api.MediaPreProcessor
|
||||
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
||||
import io.element.android.tests.testutils.simulateLongTask
|
||||
import java.io.File
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.toJavaDuration
|
||||
|
||||
class FakeMediaPreProcessor : MediaPreProcessor {
|
||||
|
||||
@@ -53,4 +56,19 @@ class FakeMediaPreProcessor : MediaPreProcessor {
|
||||
fun givenResult(value: Result<MediaUploadInfo>) {
|
||||
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(
|
||||
showDialog: Boolean,
|
||||
permission: String = Manifest.permission.POST_NOTIFICATIONS
|
||||
permission: String = Manifest.permission.POST_NOTIFICATIONS,
|
||||
permissionGranted: Boolean = false,
|
||||
) = PermissionsState(
|
||||
permission = permission,
|
||||
permissionGranted = false,
|
||||
permissionGranted = permissionGranted,
|
||||
shouldShowRationale = false,
|
||||
showDialog = showDialog,
|
||||
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.DismissTextFormattingButton
|
||||
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.TextFormatting
|
||||
import io.element.android.libraries.textcomposer.components.textInputRoundedCornerShape
|
||||
@@ -95,6 +96,7 @@ fun TextComposer(
|
||||
onAddAttachment: () -> Unit = {},
|
||||
onDismissTextFormatting: () -> Unit = {},
|
||||
onVoiceRecordButtonEvent: (PressEvent) -> Unit = {},
|
||||
onSendVoiceMessage: () -> Unit = {},
|
||||
onError: (Throwable) -> Unit = {},
|
||||
) {
|
||||
val onSendClicked = {
|
||||
@@ -137,24 +139,39 @@ fun TextComposer(
|
||||
composerMode = composerMode,
|
||||
)
|
||||
}
|
||||
val recordButton = @Composable {
|
||||
val recordVoiceButton = @Composable {
|
||||
RecordButton(
|
||||
onPressStart = { onVoiceRecordButtonEvent(PressEvent.PressStart) },
|
||||
onLongPressEnd = { onVoiceRecordButtonEvent(PressEvent.LongPressEnd) },
|
||||
onTap = { onVoiceRecordButtonEvent(PressEvent.Tapped) },
|
||||
)
|
||||
}
|
||||
val sendVoiceButton = @Composable {
|
||||
SendButton(
|
||||
canSendMessage = voiceMessageState is VoiceMessageState.Preview,
|
||||
onClick = { onSendVoiceMessage() },
|
||||
composerMode = composerMode,
|
||||
)
|
||||
}
|
||||
|
||||
val textFormattingOptions = @Composable { TextFormatting(state = state) }
|
||||
|
||||
val sendOrRecordButton = if (canSendMessage || !enableVoiceMessages) {
|
||||
sendButton
|
||||
} else {
|
||||
recordButton
|
||||
val sendOrRecordButton = when {
|
||||
enableVoiceMessages && !canSendMessage ->
|
||||
when (voiceMessageState) {
|
||||
is VoiceMessageState.Preview -> sendVoiceButton
|
||||
else -> recordVoiceButton
|
||||
}
|
||||
else ->
|
||||
sendButton
|
||||
}
|
||||
|
||||
val recordingProgress = @Composable {
|
||||
RecordingProgress()
|
||||
val voiceRecording = @Composable {
|
||||
if (voiceMessageState is VoiceMessageState.Recording) {
|
||||
VoiceMessageRecording(voiceMessageState.level)
|
||||
} else if (voiceMessageState is VoiceMessageState.Preview) {
|
||||
VoiceMessagePreview()
|
||||
}
|
||||
}
|
||||
|
||||
if (showTextFormatting) {
|
||||
@@ -170,11 +187,12 @@ fun TextComposer(
|
||||
} else {
|
||||
StandardLayout(
|
||||
voiceMessageState = voiceMessageState,
|
||||
enableVoiceMessages = enableVoiceMessages,
|
||||
modifier = layoutModifier,
|
||||
composerOptionsButton = composerOptionsButton,
|
||||
textInput = textInput,
|
||||
endButton = sendOrRecordButton,
|
||||
recordingProgress = recordingProgress,
|
||||
voiceRecording = voiceRecording,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -190,9 +208,10 @@ fun TextComposer(
|
||||
@Composable
|
||||
private fun StandardLayout(
|
||||
voiceMessageState: VoiceMessageState,
|
||||
enableVoiceMessages: Boolean,
|
||||
textInput: @Composable () -> Unit,
|
||||
composerOptionsButton: @Composable () -> Unit,
|
||||
recordingProgress: @Composable () -> Unit,
|
||||
voiceRecording: @Composable () -> Unit,
|
||||
endButton: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -200,13 +219,13 @@ private fun StandardLayout(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
) {
|
||||
if (voiceMessageState is VoiceMessageState.Recording) {
|
||||
if (enableVoiceMessages && voiceMessageState !is VoiceMessageState.Idle) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
|
||||
.weight(1f)
|
||||
) {
|
||||
recordingProgress()
|
||||
voiceRecording()
|
||||
}
|
||||
} else {
|
||||
Box(
|
||||
|
||||
@@ -17,14 +17,10 @@
|
||||
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.fillMaxWidth
|
||||
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
|
||||
@@ -36,7 +32,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
|
||||
@Composable
|
||||
internal fun RecordingProgress(
|
||||
internal fun VoiceMessagePreview(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
@@ -50,16 +46,9 @@ internal fun RecordingProgress(
|
||||
.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
|
||||
// TODO Replace with recording preview UI
|
||||
Text(
|
||||
text = "Recording...", // Not localized because it is a placeholder
|
||||
text = "Finished recording", // Not localized because it is a placeholder
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
style = ElementTheme.typography.fontBodySmMedium
|
||||
)
|
||||
@@ -68,6 +57,6 @@ internal fun RecordingProgress(
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun RecordingProgressPreview() = ElementPreview {
|
||||
RecordingProgress()
|
||||
internal fun VoiceMessagePreviewPreview() = ElementPreview {
|
||||
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 {
|
||||
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:usersearch:impl"))
|
||||
implementation(project(":libraries:textcomposer:impl"))
|
||||
implementation(project(":libraries:voicerecorder:impl"))
|
||||
}
|
||||
|
||||
fun DependencyHandlerScope.allServicesImpl() {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user