Add voice message recording duration indicator and limit (#1628)
--------- Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
parent
bdc52332bb
commit
9046ac4c8a
22 changed files with 263 additions and 35 deletions
|
|
@ -141,7 +141,10 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
||||||
|
|
||||||
return VoiceMessageComposerState(
|
return VoiceMessageComposerState(
|
||||||
voiceMessageState = when (val state = recorderState) {
|
voiceMessageState = when (val state = recorderState) {
|
||||||
is VoiceRecorderState.Recording -> VoiceMessageState.Recording(level = state.level)
|
is VoiceRecorderState.Recording -> VoiceMessageState.Recording(
|
||||||
|
duration = state.elapsedTime,
|
||||||
|
level = state.level
|
||||||
|
)
|
||||||
is VoiceRecorderState.Finished -> if (isSending) {
|
is VoiceRecorderState.Finished -> if (isSending) {
|
||||||
VoiceMessageState.Sending
|
VoiceMessageState.Sending
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,12 @@ package io.element.android.features.messages.impl.voicemessages
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
internal open class VoiceMessageComposerStateProvider : PreviewParameterProvider<VoiceMessageComposerState> {
|
internal open class VoiceMessageComposerStateProvider : PreviewParameterProvider<VoiceMessageComposerState> {
|
||||||
override val values: Sequence<VoiceMessageComposerState>
|
override val values: Sequence<VoiceMessageComposerState>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aVoiceMessageComposerState(voiceMessageState = VoiceMessageState.Recording(level = 0.5)),
|
aVoiceMessageComposerState(voiceMessageState = VoiceMessageState.Recording(duration = 61.seconds, level = 0.5)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,18 +46,26 @@ import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
class VoiceMessageComposerPresenterTest {
|
class VoiceMessageComposerPresenterTest {
|
||||||
|
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val warmUpRule = WarmUpRule()
|
val warmUpRule = WarmUpRule()
|
||||||
|
|
||||||
private val voiceRecorder = FakeVoiceRecorder()
|
private val voiceRecorder = FakeVoiceRecorder(
|
||||||
|
recordingDuration = RECORDING_DURATION
|
||||||
|
)
|
||||||
private val analyticsService = FakeAnalyticsService()
|
private val analyticsService = FakeAnalyticsService()
|
||||||
private val matrixRoom = FakeMatrixRoom()
|
private val matrixRoom = FakeMatrixRoom()
|
||||||
private val mediaPreProcessor = FakeMediaPreProcessor().apply { givenAudioResult() }
|
private val mediaPreProcessor = FakeMediaPreProcessor().apply { givenAudioResult() }
|
||||||
private val mediaSender = MediaSender(mediaPreProcessor, matrixRoom)
|
private val mediaSender = MediaSender(mediaPreProcessor, matrixRoom)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val RECORDING_DURATION = 1.seconds
|
||||||
|
private val RECORDING_STATE = VoiceMessageState.Recording(RECORDING_DURATION, 0.2)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - initial state`() = runTest {
|
fun `present - initial state`() = runTest {
|
||||||
val presenter = createVoiceMessageComposerPresenter()
|
val presenter = createVoiceMessageComposerPresenter()
|
||||||
|
|
@ -80,7 +88,7 @@ class VoiceMessageComposerPresenterTest {
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||||
|
|
||||||
val finalState = awaitItem()
|
val finalState = awaitItem()
|
||||||
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Recording(0.2))
|
assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
|
||||||
|
|
||||||
testPauseAndDestroy(finalState)
|
testPauseAndDestroy(finalState)
|
||||||
}
|
}
|
||||||
|
|
@ -270,7 +278,7 @@ class VoiceMessageComposerPresenterTest {
|
||||||
|
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||||
val finalState = awaitItem()
|
val finalState = awaitItem()
|
||||||
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Recording(0.2))
|
assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
|
||||||
|
|
||||||
testPauseAndDestroy(finalState)
|
testPauseAndDestroy(finalState)
|
||||||
}
|
}
|
||||||
|
|
@ -303,7 +311,7 @@ class VoiceMessageComposerPresenterTest {
|
||||||
|
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||||
val finalState = awaitItem()
|
val finalState = awaitItem()
|
||||||
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Recording(0.2))
|
assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
|
||||||
|
|
||||||
testPauseAndDestroy(finalState)
|
testPauseAndDestroy(finalState)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ dependencies {
|
||||||
implementation(projects.libraries.matrixui)
|
implementation(projects.libraries.matrixui)
|
||||||
implementation(projects.libraries.designsystem)
|
implementation(projects.libraries.designsystem)
|
||||||
implementation(projects.libraries.testtags)
|
implementation(projects.libraries.testtags)
|
||||||
|
implementation(projects.libraries.uiUtils)
|
||||||
|
|
||||||
implementation(libs.matrix.richtexteditor)
|
implementation(libs.matrix.richtexteditor)
|
||||||
api(libs.matrix.richtexteditor.compose)
|
api(libs.matrix.richtexteditor.compose)
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@ import io.element.android.wysiwyg.compose.RichTextEditor
|
||||||
import io.element.android.wysiwyg.compose.RichTextEditorState
|
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TextComposer(
|
fun TextComposer(
|
||||||
|
|
@ -181,7 +182,7 @@ fun TextComposer(
|
||||||
VoiceMessageState.Sending ->
|
VoiceMessageState.Sending ->
|
||||||
VoiceMessagePreview(isInteractive = false)
|
VoiceMessagePreview(isInteractive = false)
|
||||||
is VoiceMessageState.Recording ->
|
is VoiceMessageState.Recording ->
|
||||||
VoiceMessageRecording(voiceMessageState.level)
|
VoiceMessageRecording(voiceMessageState.level, voiceMessageState.duration)
|
||||||
VoiceMessageState.Idle -> {}
|
VoiceMessageState.Idle -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -751,7 +752,7 @@ internal fun TextComposerVoicePreview() = ElementPreview {
|
||||||
enableVoiceMessages = true,
|
enableVoiceMessages = true,
|
||||||
)
|
)
|
||||||
PreviewColumn(items = persistentListOf({
|
PreviewColumn(items = persistentListOf({
|
||||||
VoicePreview(voiceMessageState = VoiceMessageState.Recording(0.5))
|
VoicePreview(voiceMessageState = VoiceMessageState.Recording(61.seconds, 0.5))
|
||||||
}, {
|
}, {
|
||||||
VoicePreview(voiceMessageState = VoiceMessageState.Preview)
|
VoicePreview(voiceMessageState = VoiceMessageState.Preview)
|
||||||
}, {
|
}, {
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,14 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.theme.ElementTheme
|
import io.element.android.libraries.theme.ElementTheme
|
||||||
|
import io.element.android.libraries.ui.utils.time.formatShort
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun VoiceMessageRecording(
|
internal fun VoiceMessageRecording(
|
||||||
level: Double,
|
level: Double,
|
||||||
|
duration: Duration,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -53,16 +57,13 @@ internal fun VoiceMessageRecording(
|
||||||
.heightIn(26.dp),
|
.heightIn(26.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Box(
|
RedRecordingDot()
|
||||||
modifier = Modifier
|
|
||||||
.size(8.dp)
|
|
||||||
.background(color = ElementTheme.colors.textCriticalPrimary, shape = CircleShape)
|
|
||||||
)
|
|
||||||
Spacer(Modifier.size(8.dp))
|
Spacer(Modifier.size(8.dp))
|
||||||
|
|
||||||
// TODO Replace with timer UI
|
// Timer
|
||||||
Text(
|
Text(
|
||||||
text = "Recording...", // Not localized because it is a placeholder
|
text = duration.formatShort(),
|
||||||
color = ElementTheme.colors.textSecondary,
|
color = ElementTheme.colors.textSecondary,
|
||||||
style = ElementTheme.typography.fontBodySmMedium
|
style = ElementTheme.typography.fontBodySmMedium
|
||||||
)
|
)
|
||||||
|
|
@ -95,8 +96,17 @@ private fun DebugAudioLevel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RedRecordingDot(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) = Box(
|
||||||
|
modifier = modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.background(color = ElementTheme.colors.textCriticalPrimary, shape = CircleShape)
|
||||||
|
)
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
internal fun VoiceMessageRecordingPreview() = ElementPreview {
|
internal fun VoiceMessageRecordingPreview() = ElementPreview {
|
||||||
VoiceMessageRecording(0.5)
|
VoiceMessageRecording(0.5, 0.seconds)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,15 @@
|
||||||
|
|
||||||
package io.element.android.libraries.textcomposer.model
|
package io.element.android.libraries.textcomposer.model
|
||||||
|
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
sealed class VoiceMessageState {
|
sealed class VoiceMessageState {
|
||||||
data object Idle: VoiceMessageState()
|
data object Idle: VoiceMessageState()
|
||||||
|
|
||||||
data object Preview: VoiceMessageState()
|
data object Preview: VoiceMessageState()
|
||||||
data object Sending: VoiceMessageState()
|
data object Sending: VoiceMessageState()
|
||||||
data class Recording(
|
data class Recording(
|
||||||
|
val duration: Duration,
|
||||||
val level: Double,
|
val level: Double,
|
||||||
): VoiceMessageState()
|
): VoiceMessageState()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
28
libraries/ui-utils/build.gradle.kts
Normal file
28
libraries/ui-utils/build.gradle.kts
Normal file
|
|
@ -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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("io.element.android-library")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "io.element.android.libraries.ui.utils"
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
testImplementation(libs.test.junit)
|
||||||
|
testImplementation(libs.test.truth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
/*
|
||||||
|
* 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.ui.utils.time
|
||||||
|
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a duration as minutes:seconds.
|
||||||
|
*
|
||||||
|
* For example,
|
||||||
|
* - 0 seconds will be formatted as "0:00".
|
||||||
|
* - 65 seconds will be formatted as "1:05".
|
||||||
|
* - 2 hours will be formatted as "120:00".
|
||||||
|
* - negative 10 seconds will be formatted as "-0:10".
|
||||||
|
*
|
||||||
|
* @return the formatted duration.
|
||||||
|
*/
|
||||||
|
fun Duration.formatShort(): String {
|
||||||
|
// Format as minutes:seconds
|
||||||
|
val seconds = (absoluteValue.inWholeSeconds % 60)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, '0')
|
||||||
|
|
||||||
|
val sign = isNegative().let { if (it) "-" else "" }
|
||||||
|
|
||||||
|
return "$sign${absoluteValue.inWholeMinutes}:$seconds"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
/*
|
||||||
|
* 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.ui.utils.time
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.junit.runners.Parameterized
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
@RunWith(value = Parameterized::class)
|
||||||
|
class DurationFormatTest(
|
||||||
|
private val seconds: Double,
|
||||||
|
private val output: String,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
@Parameterized.Parameters(name = "{index}: format({0})={1}")
|
||||||
|
@JvmStatic
|
||||||
|
fun data(): Iterable<Array<Any>> {
|
||||||
|
return arrayListOf(
|
||||||
|
arrayOf<Any>(0, "0:00"),
|
||||||
|
arrayOf<Any>(1, "0:01"),
|
||||||
|
arrayOf<Any>(10, "0:10"),
|
||||||
|
arrayOf<Any>(59.9, "0:59"),
|
||||||
|
arrayOf<Any>(60, "1:00"),
|
||||||
|
arrayOf<Any>(61, "1:01"),
|
||||||
|
arrayOf<Any>(60 * 60, "60:00"),
|
||||||
|
arrayOf<Any>(-60, "-1:00"),
|
||||||
|
arrayOf<Any>(-1, "-0:01"),
|
||||||
|
).toList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun formatShort() {
|
||||||
|
assertEquals(output, seconds.seconds.formatShort())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
package io.element.android.libraries.voicerecorder.api
|
package io.element.android.libraries.voicerecorder.api
|
||||||
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
sealed class VoiceRecorderState {
|
sealed class VoiceRecorderState {
|
||||||
/**
|
/**
|
||||||
|
|
@ -27,9 +28,10 @@ sealed class VoiceRecorderState {
|
||||||
/**
|
/**
|
||||||
* The recorder is currently recording.
|
* The recorder is currently recording.
|
||||||
*
|
*
|
||||||
|
* @property elapsedTime The elapsed time since the recording started.
|
||||||
* @property level The current audio level of the recording as a fraction of 1.
|
* @property level The current audio level of the recording as a fraction of 1.
|
||||||
*/
|
*/
|
||||||
data class Recording(val level: Double) : VoiceRecorderState()
|
data class Recording(val elapsedTime: Duration, val level: Double) : VoiceRecorderState()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The recorder has finished recording.
|
* The recorder has finished recording.
|
||||||
|
|
|
||||||
|
|
@ -37,15 +37,19 @@ import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.yield
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
import kotlin.time.TimeSource
|
||||||
|
|
||||||
@SingleIn(RoomScope::class)
|
@SingleIn(RoomScope::class)
|
||||||
@ContributesBinding(RoomScope::class)
|
@ContributesBinding(RoomScope::class)
|
||||||
class VoiceRecorderImpl @Inject constructor(
|
class VoiceRecorderImpl @Inject constructor(
|
||||||
private val dispatchers: CoroutineDispatchers,
|
private val dispatchers: CoroutineDispatchers,
|
||||||
|
private val timeSource: TimeSource,
|
||||||
private val audioReaderFactory: AudioReader.Factory,
|
private val audioReaderFactory: AudioReader.Factory,
|
||||||
private val encoder: Encoder,
|
private val encoder: Encoder,
|
||||||
private val fileManager: VoiceFileManager,
|
private val fileManager: VoiceFileManager,
|
||||||
|
|
@ -74,16 +78,27 @@ class VoiceRecorderImpl @Inject constructor(
|
||||||
val audioRecorder = audioReaderFactory.create(config, dispatchers).also { audioReader = it }
|
val audioRecorder = audioReaderFactory.create(config, dispatchers).also { audioReader = it }
|
||||||
|
|
||||||
recordingJob = voiceCoroutineScope.launch {
|
recordingJob = voiceCoroutineScope.launch {
|
||||||
|
val startedAt = timeSource.markNow()
|
||||||
audioRecorder.record { audio ->
|
audioRecorder.record { audio ->
|
||||||
|
yield()
|
||||||
|
|
||||||
|
val elapsedTime = startedAt.elapsedNow()
|
||||||
|
|
||||||
|
if (elapsedTime >= 30.minutes) {
|
||||||
|
Timber.w("Voice message time limit reached")
|
||||||
|
stopRecord(false)
|
||||||
|
return@record
|
||||||
|
}
|
||||||
|
|
||||||
when (audio) {
|
when (audio) {
|
||||||
is Audio.Data -> {
|
is Audio.Data -> {
|
||||||
val audioLevel = audioLevelCalculator.calculateAudioLevel(audio.buffer)
|
val audioLevel = audioLevelCalculator.calculateAudioLevel(audio.buffer)
|
||||||
_state.emit(VoiceRecorderState.Recording(audioLevel))
|
_state.emit(VoiceRecorderState.Recording(elapsedTime, audioLevel))
|
||||||
encoder.encode(audio.buffer, audio.readSize)
|
encoder.encode(audio.buffer, audio.readSize)
|
||||||
}
|
}
|
||||||
is Audio.Error -> {
|
is Audio.Error -> {
|
||||||
Timber.e("Voice message error: code=${audio.audioRecordErrorCode}")
|
Timber.e("Voice message error: code=${audio.audioRecordErrorCode}")
|
||||||
_state.emit(VoiceRecorderState.Recording(0.0))
|
_state.emit(VoiceRecorderState.Recording(elapsedTime, 0.0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,13 @@ import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.BeforeClass
|
import org.junit.BeforeClass
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
import kotlin.time.TestTimeSource
|
||||||
|
|
||||||
class VoiceRecorderImplTest {
|
class VoiceRecorderImplTest {
|
||||||
private val fakeFileSystem = FakeFileSystem()
|
private val fakeFileSystem = FakeFileSystem()
|
||||||
|
private val timeSource = TestTimeSource()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `it emits the initial state`() = runTest {
|
fun `it emits the initial state`() = runTest {
|
||||||
|
|
@ -56,9 +60,27 @@ class VoiceRecorderImplTest {
|
||||||
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle)
|
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle)
|
||||||
|
|
||||||
voiceRecorder.startRecord()
|
voiceRecorder.startRecord()
|
||||||
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(1.0))
|
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(0.seconds, 1.0))
|
||||||
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(0.0))
|
timeSource += 1.seconds
|
||||||
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(1.0))
|
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(1.seconds,0.0))
|
||||||
|
timeSource += 1.seconds
|
||||||
|
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(2.seconds, 1.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when elapsed time reaches 30 minutes, it stops recording`() = runTest {
|
||||||
|
val voiceRecorder = createVoiceRecorder()
|
||||||
|
voiceRecorder.state.test {
|
||||||
|
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle)
|
||||||
|
|
||||||
|
voiceRecorder.startRecord()
|
||||||
|
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(0.minutes, 1.0))
|
||||||
|
timeSource += 29.minutes
|
||||||
|
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(29.minutes, 0.0))
|
||||||
|
timeSource += 1.minutes
|
||||||
|
|
||||||
|
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Finished(File(FILE_PATH), "audio/ogg"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -94,6 +116,7 @@ class VoiceRecorderImplTest {
|
||||||
val fileConfig = VoiceRecorderModule.provideVoiceFileConfig()
|
val fileConfig = VoiceRecorderModule.provideVoiceFileConfig()
|
||||||
return VoiceRecorderImpl(
|
return VoiceRecorderImpl(
|
||||||
dispatchers = testCoroutineDispatchers(),
|
dispatchers = testCoroutineDispatchers(),
|
||||||
|
timeSource = timeSource,
|
||||||
audioReaderFactory = FakeAudioRecorderFactory(
|
audioReaderFactory = FakeAudioRecorderFactory(
|
||||||
audio = AUDIO,
|
audio = AUDIO,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ class FakeAudioReader(
|
||||||
while (audios.hasNext()) {
|
while (audios.hasNext()) {
|
||||||
if (!isRecording) break
|
if (!isRecording) break
|
||||||
onAudio(audios.next())
|
onAudio(audios.next())
|
||||||
|
yield()
|
||||||
}
|
}
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
// do not return from the coroutine until it is cancelled
|
// do not return from the coroutine until it is cancelled
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,13 @@ import io.element.android.libraries.voicerecorder.api.VoiceRecorderState
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
import kotlin.time.TestTimeSource
|
||||||
|
|
||||||
class FakeVoiceRecorder(
|
class FakeVoiceRecorder(
|
||||||
|
private val timeSource: TestTimeSource = TestTimeSource(),
|
||||||
|
private val recordingDuration: Duration = 0.seconds,
|
||||||
private val levels: List<Double> = listOf(0.1, 0.2)
|
private val levels: List<Double> = listOf(0.1, 0.2)
|
||||||
) : VoiceRecorder {
|
) : VoiceRecorder {
|
||||||
private val _state = MutableStateFlow<VoiceRecorderState>(VoiceRecorderState.Idle)
|
private val _state = MutableStateFlow<VoiceRecorderState>(VoiceRecorderState.Idle)
|
||||||
|
|
@ -33,6 +38,7 @@ class FakeVoiceRecorder(
|
||||||
private var securityException: SecurityException? = null
|
private var securityException: SecurityException? = null
|
||||||
|
|
||||||
override suspend fun startRecord() {
|
override suspend fun startRecord() {
|
||||||
|
val startedAt = timeSource.markNow()
|
||||||
securityException?.let { throw it }
|
securityException?.let { throw it }
|
||||||
|
|
||||||
if (curRecording != null) {
|
if (curRecording != null) {
|
||||||
|
|
@ -40,8 +46,9 @@ class FakeVoiceRecorder(
|
||||||
}
|
}
|
||||||
curRecording = File("file.ogg")
|
curRecording = File("file.ogg")
|
||||||
|
|
||||||
|
timeSource += recordingDuration
|
||||||
levels.forEach {
|
levels.forEach {
|
||||||
_state.emit(VoiceRecorderState.Recording(it))
|
_state.emit(VoiceRecorderState.Recording(startedAt.elapsedNow(), it))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.services.toolbox.impl.systemclock
|
||||||
|
|
||||||
|
import com.squareup.anvil.annotations.ContributesTo
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import kotlin.time.TimeSource
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@ContributesTo(AppScope::class)
|
||||||
|
object TimeModule {
|
||||||
|
@Provides
|
||||||
|
fun timeSource(): TimeSource {
|
||||||
|
return TimeSource.Monotonic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:34c05059f7c4f997f3c4af339f548773aafbbcb688d563d559c14c75fba1c70d
|
oid sha256:3c25252b8d43f4ffb58673f375709b4811901a78676580a7dd0288b8624615d7
|
||||||
size 9039
|
size 7787
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:dd5d861bd3630a0341e6f8ef9bf16eb4135ed770bf549d4713cedb476c974cb2
|
oid sha256:55fa9c5633d3776a3401db72e2e53d9eacedd745e0db7f259f6ada5d7a8d584a
|
||||||
size 8634
|
size 7473
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:2a06a32b3b576c01074eb14f236f8043d209600fb098fdd3933b01f99055c24d
|
oid sha256:208ad62e23efd6f07e2cce08c1d1511af4c4fc2ae6bc3299134fed9efb1c55d3
|
||||||
size 8069
|
size 7238
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:cd347e9e28f7518bd4e4169426d9172ef7a107895d9fb93744daf47bf1ae0e50
|
oid sha256:8cf7662c33de6ad1b58785674cbfce1d6323fe84e35c8398b563ea65e5ff9fa7
|
||||||
size 7681
|
size 6919
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:c1bba507fdd9fc8526408d176f0519f42179a3618ff8e8a41f25b36a13a1a00f
|
oid sha256:2c2eadc9585070e9a07dda78145c337a003166d4dc990e794d71ece0f8cab4f3
|
||||||
size 18323
|
size 17134
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:c268e0a5bbdabb2355cb44820f666d2c4f298e5eeda7720328ad08ac9ac7cdc5
|
oid sha256:8443556fa3ad8ca7c928689b3fdd2db43cec62ceb8db340353c90cbd7ea76355
|
||||||
size 17357
|
size 16228
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue