Merge branch 'develop' into feature/fga/pin_settings

This commit is contained in:
ganfra 2023-10-26 11:13:52 +02:00
commit 5d98f645d2
376 changed files with 6593 additions and 384 deletions

View file

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

View file

@ -46,4 +46,6 @@ enum class AvatarSize(val dp: Dp) {
EditRoomDetails(70.dp),
NotificationsOptIn(32.dp),
CustomRoomNotificationSetting(36.dp)
}

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.
*/
package io.element.android.libraries.matrix.api.encryption
enum class BackupState {
UNKNOWN,
CREATING,
ENABLING,
RESUMING,
ENABLED,
DOWNLOADING,
DISABLING,
DISABLED;
}

View file

@ -38,4 +38,5 @@ interface NotificationSettingsService {
suspend fun setRoomMentionEnabled(enabled: Boolean): Result<Unit>
suspend fun isCallEnabled(): Result<Boolean>
suspend fun setCallEnabled(enabled: Boolean): Result<Unit>
suspend fun getRoomsWithUserDefinedRules(): Result<List<String>>
}

View file

@ -28,6 +28,7 @@ data class TracingFilterConfiguration(
Target.MATRIX_SDK_HTTP_CLIENT to LogLevel.DEBUG,
Target.MATRIX_SDK_SLIDING_SYNC to LogLevel.TRACE,
Target.MATRIX_SDK_BASE_SLIDING_SYNC to LogLevel.TRACE,
Target.MATRIX_SDK_UI_TIMELINE to LogLevel.TRACE,
)
fun getLogLevel(target: Target): LogLevel {

View file

@ -99,7 +99,7 @@ class RustMatrixClient constructor(
private val sessionDispatcher = dispatchers.io.limitedParallelism(64)
private val sessionCoroutineScope = appCoroutineScope.childScope(dispatchers.main, "Session-${sessionId}")
private val rustSyncService = RustSyncService(syncService, sessionCoroutineScope)
private val verificationService = RustSessionVerificationService(rustSyncService)
private val verificationService = RustSessionVerificationService(rustSyncService, sessionCoroutineScope)
private val pushersService = RustPushersService(
client = client,
dispatchers = dispatchers,

View file

@ -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.matrix.impl.encryption
import io.element.android.libraries.matrix.api.encryption.BackupState
import org.matrix.rustcomponents.sdk.BackupState as RustBackupState
class BackupStateMapper {
fun map(backupState: RustBackupState): BackupState {
return when (backupState) {
RustBackupState.UNKNOWN -> BackupState.UNKNOWN
RustBackupState.CREATING -> BackupState.CREATING
RustBackupState.ENABLING -> BackupState.ENABLING
RustBackupState.RESUMING -> BackupState.RESUMING
RustBackupState.ENABLED -> BackupState.ENABLED
RustBackupState.DOWNLOADING -> BackupState.DOWNLOADING
RustBackupState.DISABLING -> BackupState.DISABLING
RustBackupState.DISABLED -> BackupState.DISABLED
}
}
}

View file

@ -28,6 +28,8 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.NotificationSettings
import org.matrix.rustcomponents.sdk.NotificationSettingsDelegate
import org.matrix.rustcomponents.sdk.NotificationSettingsException
import timber.log.Timber
class RustNotificationSettingsService(
private val notificationSettings: NotificationSettings,
@ -63,7 +65,13 @@ class RustNotificationSettingsService(
isOneToOne: Boolean
): Result<Unit> = withContext(dispatchers.io) {
runCatching {
notificationSettings.setDefaultRoomNotificationMode(isEncrypted, isOneToOne, mode.let(RoomNotificationSettingsMapper::mapMode))
try {
notificationSettings.setDefaultRoomNotificationMode(isEncrypted, isOneToOne, mode.let(RoomNotificationSettingsMapper::mapMode))
} catch (exception: NotificationSettingsException.RuleNotFound) {
// `setDefaultRoomNotificationMode` updates multiple rules including unstable rules (e.g. the polls push rules defined in the MSC3930)
// since production home servers may not have these rules yet, we drop the RuleNotFound error
Timber.w("Unable to find the rule: ${exception.ruleId}")
}
}
}
@ -110,4 +118,9 @@ class RustNotificationSettingsService(
notificationSettings.setCallEnabled(enabled)
}
}
override suspend fun getRoomsWithUserDefinedRules(): Result<List<String>> =
runCatching {
notificationSettings.getRoomsWithUserDefinedRules(enabled = true)
}
}

View file

@ -23,10 +23,12 @@ import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatu
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.matrix.impl.sync.RustSyncService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import org.matrix.rustcomponents.sdk.SessionVerificationController
import org.matrix.rustcomponents.sdk.SessionVerificationControllerDelegate
import org.matrix.rustcomponents.sdk.SessionVerificationControllerInterface
@ -35,6 +37,7 @@ import javax.inject.Inject
class RustSessionVerificationService @Inject constructor(
private val syncService: RustSyncService,
private val sessionCoroutineScope: CoroutineScope,
) : SessionVerificationService, SessionVerificationControllerDelegate {
var verificationController: SessionVerificationControllerInterface? = null
@ -44,7 +47,7 @@ class RustSessionVerificationService @Inject constructor(
// If status was 'Unknown', move it to either 'Verified' or 'NotVerified'
if (value != null) {
value.setDelegate(this)
updateVerificationStatus(value.isVerified())
sessionCoroutineScope.launch { updateVerificationStatus(value.isVerified()) }
}
}

View file

@ -43,9 +43,11 @@ val A_USER_ID_10 = UserId("@walter:server.org")
val A_SESSION_ID: SessionId = A_USER_ID
val A_SESSION_ID_2: SessionId = A_USER_ID_2
val A_SPACE_ID = SpaceId("!aSpaceId:domain")
val A_SPACE_ID_2 = SpaceId("!aSpaceId2:domain")
val A_ROOM_ID = RoomId("!aRoomId:domain")
val A_ROOM_ID_2 = RoomId("!aRoomId2:domain")
val A_THREAD_ID = ThreadId("\$aThreadId")
val A_THREAD_ID_2 = ThreadId("\$aThreadId2")
val AN_EVENT_ID = EventId("\$anEventId")
val AN_EVENT_ID_2 = EventId("\$anEventId2")
val A_TRANSACTION_ID = TransactionId("aTransactionId")

View file

@ -20,12 +20,14 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NOTIFICATION_MODE
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
class FakeNotificationSettingsService(
initialRoomMode: RoomNotificationMode = A_ROOM_NOTIFICATION_MODE,
initialRoomModeIsDefault: Boolean = true,
initialGroupDefaultMode: RoomNotificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
initialEncryptedGroupDefaultMode: RoomNotificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
initialOneToOneDefaultMode: RoomNotificationMode = RoomNotificationMode.ALL_MESSAGES,
@ -37,16 +39,21 @@ class FakeNotificationSettingsService(
private var defaultOneToOneRoomNotificationMode: RoomNotificationMode = initialOneToOneDefaultMode
private var defaultEncryptedOneToOneRoomNotificationMode: RoomNotificationMode = initialEncryptedOneToOneDefaultMode
private var roomNotificationMode: RoomNotificationMode = initialRoomMode
private var roomNotificationModeIsDefault: Boolean = initialRoomModeIsDefault
private var callNotificationsEnabled = false
private var atRoomNotificationsEnabled = false
private var setNotificationModeError: Throwable? = null
private var restoreDefaultNotificationModeError: Throwable? = null
private var setDefaultNotificationModeError: Throwable? = null
private var setAtRoomError: Throwable? = null
override val notificationSettingsChangeFlow: SharedFlow<Unit>
get() = _notificationSettingsStateFlow
override suspend fun getRoomNotificationSettings(roomId: RoomId, isEncrypted: Boolean, isOneToOne: Boolean): Result<RoomNotificationSettings> {
return Result.success(
RoomNotificationSettings(
mode = roomNotificationMode,
isDefault = roomNotificationMode == defaultEncryptedGroupRoomNotificationMode
mode = if(roomNotificationModeIsDefault) defaultEncryptedGroupRoomNotificationMode else roomNotificationMode,
isDefault = roomNotificationModeIsDefault
)
)
}
@ -68,6 +75,10 @@ class FakeNotificationSettingsService(
}
override suspend fun setDefaultRoomNotificationMode(isEncrypted: Boolean, mode: RoomNotificationMode, isOneToOne: Boolean): Result<Unit> {
val error = setDefaultNotificationModeError
if (error != null) {
return Result.failure(error)
}
if (isOneToOne) {
if (isEncrypted) {
defaultEncryptedOneToOneRoomNotificationMode = mode
@ -86,12 +97,23 @@ class FakeNotificationSettingsService(
}
override suspend fun setRoomNotificationMode(roomId: RoomId, mode: RoomNotificationMode): Result<Unit> {
roomNotificationMode = mode
_notificationSettingsStateFlow.emit(Unit)
return Result.success(Unit)
val error = setNotificationModeError
return if (error != null) {
Result.failure(error)
} else {
roomNotificationModeIsDefault = false
roomNotificationMode = mode
_notificationSettingsStateFlow.emit(Unit)
Result.success(Unit)
}
}
override suspend fun restoreDefaultRoomNotificationMode(roomId: RoomId): Result<Unit> {
val error = restoreDefaultNotificationModeError
if (error != null) {
return Result.failure(error)
}
roomNotificationModeIsDefault = true
roomNotificationMode = defaultEncryptedGroupRoomNotificationMode
_notificationSettingsStateFlow.emit(Unit)
return Result.success(Unit)
@ -110,6 +132,10 @@ class FakeNotificationSettingsService(
}
override suspend fun setRoomMentionEnabled(enabled: Boolean): Result<Unit> {
val error = setAtRoomError
if (error != null) {
return Result.failure(error)
}
atRoomNotificationsEnabled = enabled
return Result.success(Unit)
}
@ -122,4 +148,25 @@ class FakeNotificationSettingsService(
callNotificationsEnabled = enabled
return Result.success(Unit)
}
override suspend fun getRoomsWithUserDefinedRules(): Result<List<String>> {
return Result.success(if (roomNotificationModeIsDefault) listOf() else listOf(A_ROOM_ID.value))
}
fun givenSetNotificationModeError(throwable: Throwable?) {
setNotificationModeError = throwable
}
fun givenRestoreDefaultNotificationModeError(throwable: Throwable?) {
restoreDefaultNotificationModeError = throwable
}
fun givenSetAtRoomError(throwable: Throwable?) {
setAtRoomError = throwable
}
fun givenSetDefaultNotificationModeError(throwable: Throwable?) {
setDefaultNotificationModeError = throwable
}
}

View file

@ -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,

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

View file

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

View file

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

View file

@ -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,

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

@ -50,6 +50,7 @@ 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.text.applyScaleUp
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
@ -64,9 +65,11 @@ 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.SendButton
import io.element.android.libraries.textcomposer.components.TextFormatting
import io.element.android.libraries.textcomposer.components.VoiceMessageDeleteButton
import io.element.android.libraries.textcomposer.components.VoiceMessagePreview
import io.element.android.libraries.textcomposer.components.VoiceMessageRecording
import io.element.android.libraries.textcomposer.components.textInputRoundedCornerShape
import io.element.android.libraries.textcomposer.model.Message
import io.element.android.libraries.textcomposer.model.MessageComposerMode
@ -78,6 +81,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(
@ -95,6 +99,8 @@ fun TextComposer(
onAddAttachment: () -> Unit = {},
onDismissTextFormatting: () -> Unit = {},
onVoiceRecordButtonEvent: (PressEvent) -> Unit = {},
onSendVoiceMessage: () -> Unit = {},
onDeleteVoiceMessage: () -> Unit = {},
onError: (Throwable) -> Unit = {},
) {
val onSendClicked = {
@ -137,24 +143,60 @@ 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 uploadVoiceProgress = @Composable {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
)
}
val textFormattingOptions = @Composable { TextFormatting(state = state) }
val sendOrRecordButton = if (canSendMessage || !enableVoiceMessages) {
sendButton
} else {
recordButton
val sendOrRecordButton = when {
enableVoiceMessages && !canSendMessage ->
when (voiceMessageState) {
VoiceMessageState.Idle,
is VoiceMessageState.Recording -> recordVoiceButton
is VoiceMessageState.Preview -> sendVoiceButton
is VoiceMessageState.Sending -> uploadVoiceProgress
}
else ->
sendButton
}
val recordingProgress = @Composable {
RecordingProgress()
val voiceRecording = @Composable {
when (voiceMessageState) {
VoiceMessageState.Preview ->
VoiceMessagePreview(isInteractive = true)
VoiceMessageState.Sending ->
VoiceMessagePreview(isInteractive = false)
is VoiceMessageState.Recording ->
VoiceMessageRecording(voiceMessageState.level, voiceMessageState.duration)
VoiceMessageState.Idle -> {}
}
}
val voiceDeleteButton = @Composable {
val enabled = when (voiceMessageState) {
VoiceMessageState.Preview -> true
VoiceMessageState.Sending,
is VoiceMessageState.Recording,
VoiceMessageState.Idle -> false
}
VoiceMessageDeleteButton(enabled = enabled, onClick = onDeleteVoiceMessage)
}
if (showTextFormatting) {
@ -170,11 +212,13 @@ fun TextComposer(
} else {
StandardLayout(
voiceMessageState = voiceMessageState,
enableVoiceMessages = enableVoiceMessages,
modifier = layoutModifier,
composerOptionsButton = composerOptionsButton,
textInput = textInput,
endButton = sendOrRecordButton,
recordingProgress = recordingProgress,
voiceRecording = voiceRecording,
voiceDeleteButton = voiceDeleteButton,
)
}
@ -190,9 +234,11 @@ fun TextComposer(
@Composable
private fun StandardLayout(
voiceMessageState: VoiceMessageState,
enableVoiceMessages: Boolean,
textInput: @Composable () -> Unit,
composerOptionsButton: @Composable () -> Unit,
recordingProgress: @Composable () -> Unit,
voiceRecording: @Composable () -> Unit,
voiceDeleteButton: @Composable () -> Unit,
endButton: @Composable () -> Unit,
modifier: Modifier = Modifier,
) {
@ -200,13 +246,25 @@ private fun StandardLayout(
modifier = modifier,
verticalAlignment = Alignment.Bottom,
) {
if (voiceMessageState is VoiceMessageState.Recording) {
if (enableVoiceMessages && voiceMessageState !is VoiceMessageState.Idle) {
if (voiceMessageState is VoiceMessageState.Preview || voiceMessageState is VoiceMessageState.Sending) {
Box(
modifier = Modifier
.padding(bottom = 5.dp, top = 5.dp, end = 3.dp, start = 3.dp)
.size(48.dp.applyScaleUp()),
contentAlignment = Alignment.Center,
) {
voiceDeleteButton()
}
} else {
Spacer(modifier = Modifier.width(16.dp))
}
Box(
modifier = Modifier
.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
.padding(bottom = 8.dp, top = 8.dp)
.weight(1f)
) {
recordingProgress()
voiceRecording()
}
} else {
Box(
@ -226,6 +284,8 @@ private fun StandardLayout(
Box(
Modifier
.padding(bottom = 5.dp, top = 5.dp, end = 6.dp, start = 6.dp)
.size(48.dp.applyScaleUp()),
contentAlignment = Alignment.Center,
) {
endButton()
}
@ -702,6 +762,30 @@ internal fun TextComposerReplyPreview() = ElementPreview {
)
}
@PreviewsDayNight
@Composable
internal fun TextComposerVoicePreview() = ElementPreview {
@Composable
fun VoicePreview(
voiceMessageState: VoiceMessageState
) = TextComposer(
RichTextEditorState("", initialFocus = true),
voiceMessageState = voiceMessageState,
onSendMessage = {},
composerMode = MessageComposerMode.Normal,
onResetComposerMode = {},
enableTextFormatting = true,
enableVoiceMessages = true,
)
PreviewColumn(items = persistentListOf({
VoicePreview(voiceMessageState = VoiceMessageState.Recording(61.seconds, 0.5))
}, {
VoicePreview(voiceMessageState = VoiceMessageState.Preview)
}, {
VoicePreview(voiceMessageState = VoiceMessageState.Sending)
}))
}
@Composable
private fun PreviewColumn(
items: ImmutableList<@Composable () -> Unit>,

View file

@ -0,0 +1,66 @@
/*
* 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.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
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.text.applyScaleUp
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun VoiceMessageDeleteButton(
enabled: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
) {
IconButton(
modifier = modifier
.size(48.dp),
enabled = enabled,
onClick = onClick,
) {
Icon(
modifier = Modifier.size(24.dp.applyScaleUp()),
resourceId = CommonDrawables.ic_compound_delete,
contentDescription = stringResource(CommonStrings.a11y_delete),
tint = if (enabled) {
ElementTheme.colors.iconCriticalPrimary
} else {
ElementTheme.colors.iconDisabled
},
)
}
}
@PreviewsDayNight
@Composable
internal fun VoiceMessageDeleteButtonPreview() = ElementPreview {
Row {
VoiceMessageDeleteButton(enabled = true)
VoiceMessageDeleteButton(enabled = false)
}
}

View file

@ -17,14 +17,11 @@
package io.element.android.libraries.textcomposer.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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 +33,8 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme
@Composable
internal fun RecordingProgress(
internal fun VoiceMessagePreview(
isInteractive: Boolean,
modifier: Modifier = Modifier,
) {
Row(
@ -50,17 +48,14 @@ 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
color = ElementTheme.colors.textSecondary,
text = "Finished recording", // Not localized because it is a placeholder
color = if (isInteractive) {
ElementTheme.colors.textSecondary
} else {
ElementTheme.colors.textDisabled
},
style = ElementTheme.typography.fontBodySmMedium
)
}
@ -68,6 +63,9 @@ internal fun RecordingProgress(
@PreviewsDayNight
@Composable
internal fun RecordingProgressPreview() = ElementPreview {
RecordingProgress()
internal fun VoiceMessagePreviewPreview() = ElementPreview {
Column {
VoiceMessagePreview(isInteractive = true)
VoiceMessagePreview(isInteractive = false)
}
}

View file

@ -0,0 +1,112 @@
/*
* 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
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(
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,
) {
RedRecordingDot()
Spacer(Modifier.size(8.dp))
// Timer
Text(
text = duration.formatShort(),
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()
)
}
}
@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, 0.seconds)
}

View file

@ -16,7 +16,15 @@
package io.element.android.libraries.textcomposer.model
import kotlin.time.Duration
sealed class VoiceMessageState {
data object Idle: VoiceMessageState()
data object Recording: 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

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

View file

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

View file

@ -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.api
import java.io.File
import kotlin.time.Duration
sealed class VoiceRecorderState {
/**
* The recorder is idle and not recording.
*/
data object Idle : 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 elapsedTime: Duration, 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()
}

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

View file

@ -0,0 +1,147 @@
/*
* 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 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,
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 {
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(elapsedTime, audioLevel))
encoder.encode(audio.buffer, audio.readSize)
}
is Audio.Error -> {
Timber.e("Voice message error: code=${audio.audioRecordErrorCode}")
_state.emit(VoiceRecorderState.Recording(elapsedTime, 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)
}
}

View file

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

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

View file

@ -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,
)

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

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

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

View file

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

View file

@ -0,0 +1,157 @@
/*
* 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
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 {
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(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"))
}
}
@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(),
timeSource = timeSource,
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()
}
}
}

View file

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

View file

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

View file

@ -0,0 +1,50 @@
/*
* 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())
yield()
}
while (isActive) {
// do not return from the coroutine until it is cancelled
yield()
}
}
}
override fun stop() {
isRecording = false
}
}

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

View file

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

View file

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

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

View file

@ -0,0 +1,31 @@
/*
* 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)
implementation(libs.test.truth)
}

View file

@ -0,0 +1,101 @@
/*
* 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 com.google.common.truth.Truth.assertThat
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
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)
override val state: StateFlow<VoiceRecorderState> = _state
private var curRecording: File? = null
private var securityException: SecurityException? = null
private var startedCount = 0
private var stoppedCount = 0
private var deletedCount = 0
override suspend fun startRecord() {
startedCount += 1
val startedAt = timeSource.markNow()
securityException?.let { throw it }
if (curRecording != null) {
error("Previous recording was not cleared")
}
curRecording = File("file.ogg")
timeSource += recordingDuration
levels.forEach {
_state.emit(VoiceRecorderState.Recording(startedAt.elapsedNow(), it))
}
}
override suspend fun stopRecord(
cancelled: Boolean
) {
stoppedCount++
if (cancelled) {
deleteRecording()
}
_state.emit(
when (curRecording) {
null -> VoiceRecorderState.Idle
else -> VoiceRecorderState.Finished(curRecording!!, "audio/ogg")
}
)
}
override suspend fun deleteRecording() {
deletedCount++
curRecording = null
_state.emit(
VoiceRecorderState.Idle
)
}
fun assertCalls(
started: Int = 0,
stopped: Int = 0,
deleted: Int = 0,
) {
assertThat(startedCount).isEqualTo(started)
assertThat(stoppedCount).isEqualTo(stopped)
assertThat(deletedCount).isEqualTo(deleted)
}
fun givenThrowsSecurityException(exception: SecurityException) {
this.securityException = exception
}
}