Skip to content

Commit

Permalink
Add voice message recording duration indicator and limit (#1628)
Browse files Browse the repository at this point in the history
---------

Co-authored-by: ElementBot <[email protected]>
  • Loading branch information
jonnyandrew and ElementBot authored Oct 24, 2023
1 parent 8c0d9cc commit f1b142f
Show file tree
Hide file tree
Showing 22 changed files with 263 additions and 35 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down
1 change: 1 addition & 0 deletions libraries/textcomposer/impl/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -181,7 +182,7 @@ fun TextComposer(
VoiceMessageState.Sending ->
VoiceMessagePreview(isInteractive = false)
is VoiceMessageState.Recording ->
VoiceMessageRecording(voiceMessageState.level)
VoiceMessageRecording(voiceMessageState.level, voiceMessageState.duration)
VoiceMessageState.Idle -> {}
}
}
Expand Down Expand Up @@ -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)
}, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
)
Expand Down Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
28 changes: 28 additions & 0 deletions libraries/ui-utils/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package io.element.android.libraries.voicerecorder.api

import java.io.File
import kotlin.time.Duration

sealed class VoiceRecorderState {
/**
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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))
}
}
}
Expand Down
Loading

0 comments on commit f1b142f

Please sign in to comment.