Add voice message recording duration indicator and limit (#1628)

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
jonnyandrew
2023-10-24 12:44:53 +01:00
committed by GitHub
parent bdc52332bb
commit 9046ac4c8a
22 changed files with 263 additions and 35 deletions

View File

@@ -141,7 +141,10 @@ class VoiceMessageComposerPresenter @Inject constructor(
return VoiceMessageComposerState(
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) {
VoiceMessageState.Sending
} else {

View File

@@ -18,11 +18,12 @@ package io.element.android.features.messages.impl.voicemessages
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.textcomposer.model.VoiceMessageState
import kotlin.time.Duration.Companion.seconds
internal open class VoiceMessageComposerStateProvider : PreviewParameterProvider<VoiceMessageComposerState> {
override val values: Sequence<VoiceMessageComposerState>
get() = sequenceOf(
aVoiceMessageComposerState(voiceMessageState = VoiceMessageState.Recording(level = 0.5)),
aVoiceMessageComposerState(voiceMessageState = VoiceMessageState.Recording(duration = 61.seconds, level = 0.5)),
)
}

View File

@@ -46,18 +46,26 @@ import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import kotlin.time.Duration.Companion.seconds
class VoiceMessageComposerPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
private val voiceRecorder = FakeVoiceRecorder()
private val voiceRecorder = FakeVoiceRecorder(
recordingDuration = RECORDING_DURATION
)
private val analyticsService = FakeAnalyticsService()
private val matrixRoom = FakeMatrixRoom()
private val mediaPreProcessor = FakeMediaPreProcessor().apply { givenAudioResult() }
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
fun `present - initial state`() = runTest {
val presenter = createVoiceMessageComposerPresenter()
@@ -80,7 +88,7 @@ class VoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Recording(0.2))
assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
testPauseAndDestroy(finalState)
}
@@ -270,7 +278,7 @@ class VoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Recording(0.2))
assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
testPauseAndDestroy(finalState)
}
@@ -303,7 +311,7 @@ class VoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Recording(0.2))
assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
testPauseAndDestroy(finalState)
}

View File

@@ -32,6 +32,7 @@ dependencies {
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiUtils)
implementation(libs.matrix.richtexteditor)
api(libs.matrix.richtexteditor.compose)

View File

@@ -80,6 +80,7 @@ import io.element.android.wysiwyg.compose.RichTextEditor
import io.element.android.wysiwyg.compose.RichTextEditorState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlin.time.Duration.Companion.seconds
@Composable
fun TextComposer(
@@ -181,7 +182,7 @@ fun TextComposer(
VoiceMessageState.Sending ->
VoiceMessagePreview(isInteractive = false)
is VoiceMessageState.Recording ->
VoiceMessageRecording(voiceMessageState.level)
VoiceMessageRecording(voiceMessageState.level, voiceMessageState.duration)
VoiceMessageState.Idle -> {}
}
}
@@ -751,7 +752,7 @@ internal fun TextComposerVoicePreview() = ElementPreview {
enableVoiceMessages = true,
)
PreviewColumn(items = persistentListOf({
VoicePreview(voiceMessageState = VoiceMessageState.Recording(0.5))
VoicePreview(voiceMessageState = VoiceMessageState.Recording(61.seconds, 0.5))
}, {
VoicePreview(voiceMessageState = VoiceMessageState.Preview)
}, {

View File

@@ -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.theme.components.Text
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
internal fun VoiceMessageRecording(
level: Double,
duration: Duration,
modifier: Modifier = Modifier,
) {
Row(
@@ -53,16 +57,13 @@ internal fun VoiceMessageRecording(
.heightIn(26.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(8.dp)
.background(color = ElementTheme.colors.textCriticalPrimary, shape = CircleShape)
)
RedRecordingDot()
Spacer(Modifier.size(8.dp))
// TODO Replace with timer UI
// Timer
Text(
text = "Recording...", // Not localized because it is a placeholder
text = duration.formatShort(),
color = ElementTheme.colors.textSecondary,
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
@Composable
internal fun VoiceMessageRecordingPreview() = ElementPreview {
VoiceMessageRecording(0.5)
VoiceMessageRecording(0.5, 0.seconds)
}

View File

@@ -16,12 +16,15 @@
package io.element.android.libraries.textcomposer.model
import kotlin.time.Duration
sealed class VoiceMessageState {
data object Idle: VoiceMessageState()
data object Preview: VoiceMessageState()
data object Sending: VoiceMessageState()
data class Recording(
val duration: Duration,
val level: Double,
): VoiceMessageState()
}

View 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)
}
}

View File

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

View File

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

View File

@@ -17,6 +17,7 @@
package io.element.android.libraries.voicerecorder.api
import java.io.File
import kotlin.time.Duration
sealed class VoiceRecorderState {
/**
@@ -27,9 +28,10 @@ sealed class VoiceRecorderState {
/**
* 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.
*/
data class Recording(val level: Double) : VoiceRecorderState()
data class Recording(val elapsedTime: Duration, val level: Double) : VoiceRecorderState()
/**
* The recorder has finished recording.

View File

@@ -37,15 +37,19 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import timber.log.Timber
import java.io.File
import java.util.UUID
import javax.inject.Inject
import kotlin.time.Duration.Companion.minutes
import kotlin.time.TimeSource
@SingleIn(RoomScope::class)
@ContributesBinding(RoomScope::class)
class VoiceRecorderImpl @Inject constructor(
private val dispatchers: CoroutineDispatchers,
private val timeSource: TimeSource,
private val audioReaderFactory: AudioReader.Factory,
private val encoder: Encoder,
private val fileManager: VoiceFileManager,
@@ -74,16 +78,27 @@ class VoiceRecorderImpl @Inject constructor(
val audioRecorder = audioReaderFactory.create(config, dispatchers).also { audioReader = it }
recordingJob = voiceCoroutineScope.launch {
val startedAt = timeSource.markNow()
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) {
is Audio.Data -> {
val audioLevel = audioLevelCalculator.calculateAudioLevel(audio.buffer)
_state.emit(VoiceRecorderState.Recording(audioLevel))
_state.emit(VoiceRecorderState.Recording(elapsedTime, audioLevel))
encoder.encode(audio.buffer, audio.readSize)
}
is Audio.Error -> {
Timber.e("Voice message error: code=${audio.audioRecordErrorCode}")
_state.emit(VoiceRecorderState.Recording(0.0))
_state.emit(VoiceRecorderState.Recording(elapsedTime, 0.0))
}
}
}

View File

@@ -37,9 +37,13 @@ import kotlinx.coroutines.test.runTest
import org.junit.BeforeClass
import org.junit.Test
import java.io.File
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TestTimeSource
class VoiceRecorderImplTest {
private val fakeFileSystem = FakeFileSystem()
private val timeSource = TestTimeSource()
@Test
fun `it emits the initial state`() = runTest {
@@ -56,9 +60,27 @@ class VoiceRecorderImplTest {
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))
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(0.seconds, 1.0))
timeSource += 1.seconds
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()
return VoiceRecorderImpl(
dispatchers = testCoroutineDispatchers(),
timeSource = timeSource,
audioReaderFactory = FakeAudioRecorderFactory(
audio = AUDIO,
),

View File

@@ -35,6 +35,7 @@ class FakeAudioReader(
while (audios.hasNext()) {
if (!isRecording) break
onAudio(audios.next())
yield()
}
while (isActive) {
// do not return from the coroutine until it is cancelled

View File

@@ -21,8 +21,13 @@ import io.element.android.libraries.voicerecorder.api.VoiceRecorderState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.io.File
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TestTimeSource
class FakeVoiceRecorder(
private val timeSource: TestTimeSource = TestTimeSource(),
private val recordingDuration: Duration = 0.seconds,
private val levels: List<Double> = listOf(0.1, 0.2)
) : VoiceRecorder {
private val _state = MutableStateFlow<VoiceRecorderState>(VoiceRecorderState.Idle)
@@ -33,6 +38,7 @@ class FakeVoiceRecorder(
private var securityException: SecurityException? = null
override suspend fun startRecord() {
val startedAt = timeSource.markNow()
securityException?.let { throw it }
if (curRecording != null) {
@@ -40,8 +46,9 @@ class FakeVoiceRecorder(
}
curRecording = File("file.ogg")
timeSource += recordingDuration
levels.forEach {
_state.emit(VoiceRecorderState.Recording(it))
_state.emit(VoiceRecorderState.Recording(startedAt.elapsedNow(), it))
}
}

View 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.
*/
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
}
}