Merge branch 'develop' into feature/fga/pin_settings
This commit is contained in:
commit
5d98f645d2
376 changed files with 6593 additions and 384 deletions
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -46,4 +46,6 @@ enum class AvatarSize(val dp: Dp) {
|
|||
EditRoomDetails(70.dp),
|
||||
|
||||
NotificationsOptIn(32.dp),
|
||||
|
||||
CustomRoomNotificationSetting(36.dp)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,16 +50,43 @@ class MediaSender @Inject constructor(
|
|||
.flatMapCatching { info ->
|
||||
room.sendMedia(info, progressCallback)
|
||||
}
|
||||
.onFailure { error ->
|
||||
val job = ongoingUploadJobs.remove(Job)
|
||||
if (error !is CancellationException) {
|
||||
job?.cancel()
|
||||
}
|
||||
}
|
||||
.onSuccess {
|
||||
ongoingUploadJobs.remove(Job)
|
||||
}
|
||||
.handleSendResult()
|
||||
}
|
||||
suspend fun sendVoiceMessage(
|
||||
uri: Uri,
|
||||
mimeType: String,
|
||||
waveForm: List<Int>,
|
||||
progressCallback: ProgressCallback? = null
|
||||
): Result<Unit> {
|
||||
return preProcessor
|
||||
.process(
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
deleteOriginal = true,
|
||||
compressIfPossible = false
|
||||
)
|
||||
.flatMapCatching { info ->
|
||||
val audioInfo = (info as MediaUploadInfo.Audio).audioInfo
|
||||
val newInfo = MediaUploadInfo.VoiceMessage(
|
||||
file = info.file,
|
||||
audioInfo = audioInfo,
|
||||
waveform = waveForm,
|
||||
)
|
||||
room.sendMedia(newInfo, progressCallback)
|
||||
}
|
||||
.handleSendResult()
|
||||
}
|
||||
|
||||
private fun Result<Unit>.handleSendResult() = this
|
||||
.onFailure { error ->
|
||||
val job = ongoingUploadJobs.remove(Job)
|
||||
if (error !is CancellationException) {
|
||||
job?.cancel()
|
||||
}
|
||||
}
|
||||
.onSuccess {
|
||||
ongoingUploadJobs.remove(Job)
|
||||
}
|
||||
|
||||
private suspend fun MatrixRoom.sendMedia(
|
||||
uploadInfo: MediaUploadInfo,
|
||||
|
|
@ -90,7 +117,14 @@ class MediaSender @Inject constructor(
|
|||
progressCallback = progressCallback
|
||||
)
|
||||
}
|
||||
|
||||
is MediaUploadInfo.VoiceMessage -> {
|
||||
sendVoiceMessage(
|
||||
file = uploadInfo.file,
|
||||
audioInfo = uploadInfo.audioInfo,
|
||||
waveform = uploadInfo.waveform,
|
||||
progressCallback = progressCallback
|
||||
)
|
||||
}
|
||||
is MediaUploadInfo.AnyFile -> {
|
||||
sendFile(
|
||||
file = uploadInfo.file,
|
||||
|
|
|
|||
|
|
@ -29,5 +29,6 @@ sealed interface MediaUploadInfo {
|
|||
data class Image(override val file: File, val imageInfo: ImageInfo, val thumbnailFile: File) : MediaUploadInfo
|
||||
data class Video(override val file: File, val videoInfo: VideoInfo, val thumbnailFile: File) : MediaUploadInfo
|
||||
data class Audio(override val file: File, val audioInfo: AudioInfo) : MediaUploadInfo
|
||||
data class VoiceMessage(override val file: File, val audioInfo: AudioInfo, val waveform: List<Int>) : MediaUploadInfo
|
||||
data class AnyFile(override val file: File, val fileInfo: FileInfo) : MediaUploadInfo
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ class AndroidMediaPreProcessor @Inject constructor(
|
|||
is MediaUploadInfo.Audio -> copy(file = renamedFile)
|
||||
is MediaUploadInfo.Image -> copy(file = renamedFile)
|
||||
is MediaUploadInfo.Video -> copy(file = renamedFile)
|
||||
is MediaUploadInfo.VoiceMessage -> copy(file = renamedFile)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,11 +17,14 @@
|
|||
package io.element.android.libraries.mediaupload.test
|
||||
|
||||
import android.net.Uri
|
||||
import io.element.android.libraries.matrix.api.media.AudioInfo
|
||||
import io.element.android.libraries.matrix.api.media.FileInfo
|
||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
||||
import io.element.android.tests.testutils.simulateLongTask
|
||||
import java.io.File
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.toJavaDuration
|
||||
|
||||
class FakeMediaPreProcessor : MediaPreProcessor {
|
||||
|
||||
|
|
@ -53,4 +56,19 @@ class FakeMediaPreProcessor : MediaPreProcessor {
|
|||
fun givenResult(value: Result<MediaUploadInfo>) {
|
||||
this.result = value
|
||||
}
|
||||
|
||||
fun givenAudioResult() {
|
||||
givenResult(
|
||||
Result.success(
|
||||
MediaUploadInfo.Audio(
|
||||
file = File("audio.ogg"),
|
||||
audioInfo = AudioInfo(
|
||||
duration = 1000.seconds.toJavaDuration(),
|
||||
size = 1000,
|
||||
mimetype = "audio/ogg",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,10 +31,11 @@ open class PermissionsStateProvider : PreviewParameterProvider<PermissionsState>
|
|||
|
||||
fun aPermissionsState(
|
||||
showDialog: Boolean,
|
||||
permission: String = Manifest.permission.POST_NOTIFICATIONS
|
||||
permission: String = Manifest.permission.POST_NOTIFICATIONS,
|
||||
permissionGranted: Boolean = false,
|
||||
) = PermissionsState(
|
||||
permission = permission,
|
||||
permissionGranted = false,
|
||||
permissionGranted = permissionGranted,
|
||||
shouldShowRationale = false,
|
||||
showDialog = showDialog,
|
||||
permissionAlreadyAsked = false,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
28
libraries/ui-utils/build.gradle.kts
Normal file
28
libraries/ui-utils/build.gradle.kts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("io.element.android-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.ui.utils"
|
||||
|
||||
dependencies {
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.test.truth)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.ui.utils.time
|
||||
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* Format a duration as minutes:seconds.
|
||||
*
|
||||
* For example,
|
||||
* - 0 seconds will be formatted as "0:00".
|
||||
* - 65 seconds will be formatted as "1:05".
|
||||
* - 2 hours will be formatted as "120:00".
|
||||
* - negative 10 seconds will be formatted as "-0:10".
|
||||
*
|
||||
* @return the formatted duration.
|
||||
*/
|
||||
fun Duration.formatShort(): String {
|
||||
// Format as minutes:seconds
|
||||
val seconds = (absoluteValue.inWholeSeconds % 60)
|
||||
.toString()
|
||||
.padStart(2, '0')
|
||||
|
||||
val sign = isNegative().let { if (it) "-" else "" }
|
||||
|
||||
return "$sign${absoluteValue.inWholeMinutes}:$seconds"
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.ui.utils.time
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.Parameterized
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@RunWith(value = Parameterized::class)
|
||||
class DurationFormatTest(
|
||||
private val seconds: Double,
|
||||
private val output: String,
|
||||
) {
|
||||
companion object {
|
||||
@Parameterized.Parameters(name = "{index}: format({0})={1}")
|
||||
@JvmStatic
|
||||
fun data(): Iterable<Array<Any>> {
|
||||
return arrayListOf(
|
||||
arrayOf<Any>(0, "0:00"),
|
||||
arrayOf<Any>(1, "0:01"),
|
||||
arrayOf<Any>(10, "0:10"),
|
||||
arrayOf<Any>(59.9, "0:59"),
|
||||
arrayOf<Any>(60, "1:00"),
|
||||
arrayOf<Any>(61, "1:01"),
|
||||
arrayOf<Any>(60 * 60, "60:00"),
|
||||
arrayOf<Any>(-60, "-1:00"),
|
||||
arrayOf<Any>(-1, "-0:01"),
|
||||
).toList()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun formatShort() {
|
||||
assertEquals(output, seconds.seconds.formatShort())
|
||||
}
|
||||
}
|
||||
32
libraries/voicerecorder/api/build.gradle.kts
Normal file
32
libraries/voicerecorder/api/build.gradle.kts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
plugins {
|
||||
id("io.element.android-library")
|
||||
alias(libs.plugins.anvil)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.voicerecorder.api"
|
||||
}
|
||||
|
||||
anvil {
|
||||
generateDaggerFactories.set(true)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.annotationjvm)
|
||||
implementation(libs.coroutines.core)
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.voicerecorder.api
|
||||
|
||||
import android.Manifest
|
||||
import androidx.annotation.RequiresPermission
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Audio recorder which records audio to opus/ogg files.
|
||||
*/
|
||||
interface VoiceRecorder {
|
||||
/**
|
||||
* Start a recording.
|
||||
*
|
||||
* Call [stopRecord] to stop the recording and release resources.
|
||||
*/
|
||||
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
|
||||
suspend fun startRecord()
|
||||
|
||||
/**
|
||||
* Stop the current recording.
|
||||
*
|
||||
* Call [deleteRecording] to delete any recorded audio.
|
||||
*
|
||||
* @param cancelled If true, the recording is deleted.
|
||||
*/
|
||||
suspend fun stopRecord(
|
||||
cancelled: Boolean = false
|
||||
)
|
||||
|
||||
/**
|
||||
* Stop the current recording and delete the output file.
|
||||
*/
|
||||
suspend fun deleteRecording()
|
||||
|
||||
/**
|
||||
* The current state of the recorder.
|
||||
*/
|
||||
val state: StateFlow<VoiceRecorderState>
|
||||
}
|
||||
|
|
@ -0,0 +1,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()
|
||||
}
|
||||
48
libraries/voicerecorder/impl/build.gradle.kts
Normal file
48
libraries/voicerecorder/impl/build.gradle.kts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
plugins {
|
||||
id("io.element.android-library")
|
||||
alias(libs.plugins.anvil)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.voicerecorder.impl"
|
||||
}
|
||||
|
||||
anvil {
|
||||
generateDaggerFactories.set(true)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.libraries.voicerecorder.api)
|
||||
api(libs.opusencoder)
|
||||
|
||||
implementation(libs.dagger)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.di)
|
||||
|
||||
implementation(libs.androidx.annotationjvm)
|
||||
implementation(libs.coroutines.core)
|
||||
|
||||
testImplementation(projects.tests.testutils)
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.mockk)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(libs.coroutines.core)
|
||||
testImplementation(libs.coroutines.test)
|
||||
}
|
||||
|
|
@ -0,0 +1,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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.voicerecorder.impl.audio
|
||||
|
||||
import android.Manifest
|
||||
import android.media.AudioRecord
|
||||
import android.media.audiofx.AutomaticGainControl
|
||||
import android.media.audiofx.NoiseSuppressor
|
||||
import androidx.annotation.RequiresPermission
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.core.data.tryOrNull
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class AndroidAudioReader
|
||||
@RequiresPermission(Manifest.permission.RECORD_AUDIO) private constructor(
|
||||
private val config: AudioConfig,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) : AudioReader {
|
||||
private val audioRecord: AudioRecord
|
||||
private var noiseSuppressor: NoiseSuppressor? = null
|
||||
private var automaticGainControl: AutomaticGainControl? = null
|
||||
private val outputBuffer: ShortArray
|
||||
|
||||
init {
|
||||
outputBuffer = createOutputBuffer(config.sampleRate)
|
||||
audioRecord = AudioRecord.Builder().setAudioSource(config.source).setAudioFormat(config.format).setBufferSizeInBytes(outputBuffer.sizeInBytes()).build()
|
||||
noiseSuppressor = requestNoiseSuppressor(audioRecord)
|
||||
automaticGainControl = requestAutomaticGainControl(audioRecord)
|
||||
}
|
||||
|
||||
/**
|
||||
* Record audio data continuously.
|
||||
*
|
||||
* @param onAudio callback when audio is read.
|
||||
*/
|
||||
override suspend fun record(
|
||||
onAudio: suspend (Audio) -> Unit,
|
||||
) {
|
||||
audioRecord.startRecording()
|
||||
withContext(dispatchers.io) {
|
||||
while (isActive) {
|
||||
if (audioRecord.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
|
||||
break
|
||||
}
|
||||
onAudio(read())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun read(): Audio {
|
||||
val result = audioRecord.read(outputBuffer, 0, outputBuffer.size)
|
||||
|
||||
if (isAudioRecordErrorResult(result)) {
|
||||
return Audio.Error(result)
|
||||
}
|
||||
|
||||
return Audio.Data(
|
||||
result,
|
||||
outputBuffer,
|
||||
)
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
if (audioRecord.state == AudioRecord.STATE_INITIALIZED) {
|
||||
audioRecord.stop()
|
||||
}
|
||||
audioRecord.release()
|
||||
|
||||
noiseSuppressor?.release()
|
||||
noiseSuppressor = null
|
||||
|
||||
automaticGainControl?.release()
|
||||
automaticGainControl = null
|
||||
}
|
||||
|
||||
private fun createOutputBuffer(sampleRate: SampleRate): ShortArray {
|
||||
val bufferSizeInShorts = AudioRecord.getMinBufferSize(
|
||||
sampleRate.hz,
|
||||
config.format.channelMask,
|
||||
config.format.encoding
|
||||
)
|
||||
return ShortArray(bufferSizeInShorts)
|
||||
}
|
||||
|
||||
private fun requestNoiseSuppressor(audioRecord: AudioRecord): NoiseSuppressor? {
|
||||
if (!NoiseSuppressor.isAvailable()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return tryOrNull {
|
||||
NoiseSuppressor.create(audioRecord.audioSessionId).apply {
|
||||
enabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestAutomaticGainControl(audioRecord: AudioRecord): AutomaticGainControl? {
|
||||
if (!AutomaticGainControl.isAvailable()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return tryOrNull {
|
||||
AutomaticGainControl.create(audioRecord.audioSessionId).apply {
|
||||
enabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ContributesBinding(RoomScope::class)
|
||||
companion object Factory : AudioReader.Factory {
|
||||
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
|
||||
override fun create(config: AudioConfig, dispatchers: CoroutineDispatchers): AndroidAudioReader {
|
||||
return AndroidAudioReader(config, dispatchers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isAudioRecordErrorResult(result: Int): Boolean {
|
||||
return result < 0
|
||||
}
|
||||
|
||||
private fun ShortArray.sizeInBytes(): Int = size * Short.SIZE_BYTES
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.voicerecorder.impl.audio
|
||||
|
||||
sealed class Audio {
|
||||
class Data(
|
||||
val readSize: Int,
|
||||
val buffer: ShortArray,
|
||||
) : Audio()
|
||||
|
||||
data class Error(
|
||||
val audioRecordErrorCode: Int
|
||||
) : Audio()
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.voicerecorder.impl.audio
|
||||
|
||||
import android.media.AudioFormat
|
||||
import android.media.MediaRecorder.AudioSource
|
||||
|
||||
/**
|
||||
* Audio configuration for voice recording.
|
||||
*
|
||||
* @property source the audio source to use, see constants in [AudioSource]
|
||||
* @property format the audio format to use, see [AudioFormat]
|
||||
* @property sampleRate the sample rate to use. Ensure this matches the value set in [format].
|
||||
* @property bitRate the bitrate in bps
|
||||
*/
|
||||
data class AudioConfig(
|
||||
val source: Int,
|
||||
val format: AudioFormat,
|
||||
val sampleRate: SampleRate,
|
||||
val bitRate: Int,
|
||||
)
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.voicerecorder.impl.audio
|
||||
|
||||
interface AudioLevelCalculator {
|
||||
/**
|
||||
* Calculate the audio level of the audio buffer.
|
||||
*
|
||||
* @param buffer The audio buffer containing raw audio data.
|
||||
*
|
||||
* @return A value between 0 and 1.
|
||||
*/
|
||||
fun calculateAudioLevel(buffer: ShortArray): Double
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.voicerecorder.impl.audio
|
||||
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
|
||||
interface AudioReader {
|
||||
/**
|
||||
* Record audio data continuously.
|
||||
*
|
||||
* @param onAudio callback when audio is read.
|
||||
*/
|
||||
suspend fun record(
|
||||
onAudio: suspend (Audio) -> Unit,
|
||||
)
|
||||
|
||||
fun stop()
|
||||
|
||||
interface Factory {
|
||||
fun create(config: AudioConfig, dispatchers: CoroutineDispatchers): AudioReader
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.voicerecorder.impl.audio
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.log10
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sqrt
|
||||
|
||||
@ContributesBinding(RoomScope::class)
|
||||
class DecibelAudioLevelCalculator @Inject constructor() : AudioLevelCalculator {
|
||||
companion object {
|
||||
private const val REFERENCE_DB = 50.0 // Reference dB for normal conversation
|
||||
}
|
||||
|
||||
override fun calculateAudioLevel(buffer: ShortArray): Double {
|
||||
val rms = buffer.rootMeanSquare()
|
||||
|
||||
// Convert to decibels and clip
|
||||
val db = 20 * log10(rms / REFERENCE_DB)
|
||||
val clipped = min(db, REFERENCE_DB)
|
||||
|
||||
// Scale to the range [0.0, 1.0]
|
||||
return clipped / REFERENCE_DB
|
||||
}
|
||||
|
||||
private fun ShortArray.rootMeanSquare(): Double {
|
||||
// Use Double to avoid overflow
|
||||
val sumOfSquares: Double = sumOf { it.toDouble() * it.toDouble() }
|
||||
val avgSquare = sumOfSquares / size.toDouble()
|
||||
return sqrt(avgSquare)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.voicerecorder.impl.audio
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.opusencoder.OggOpusEncoder
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
|
||||
/**
|
||||
* Safe wrapper for OggOpusEncoder.
|
||||
*/
|
||||
@ContributesBinding(RoomScope::class)
|
||||
class DefaultEncoder @Inject constructor(
|
||||
private val encoderProvider: Provider<OggOpusEncoder>,
|
||||
config: AudioConfig,
|
||||
) : Encoder {
|
||||
private val bitRate = config.bitRate
|
||||
private val sampleRate = config.sampleRate.asEncoderModel()
|
||||
|
||||
private var encoder: OggOpusEncoder? = null
|
||||
override fun init(
|
||||
file: File,
|
||||
) {
|
||||
encoder?.release()
|
||||
encoder = encoderProvider.get().apply {
|
||||
init(file.absolutePath, sampleRate)
|
||||
setBitrate(bitRate)
|
||||
// TODO check encoder application: 2048 (voice, default is typically 2049 as audio)
|
||||
}
|
||||
}
|
||||
|
||||
override fun encode(
|
||||
buffer: ShortArray,
|
||||
readSize: Int,
|
||||
) {
|
||||
encoder?.encode(buffer, readSize)
|
||||
?: Timber.w("Can't encode when encoder not initialized")
|
||||
}
|
||||
|
||||
override fun release() {
|
||||
encoder?.release()
|
||||
?: Timber.w("Can't release encoder that is not initialized")
|
||||
encoder = null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.voicerecorder.impl.audio
|
||||
|
||||
import java.io.File
|
||||
|
||||
interface Encoder {
|
||||
|
||||
fun init(file: File)
|
||||
|
||||
fun encode(buffer: ShortArray, readSize: Int)
|
||||
|
||||
fun release()
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.voicerecorder.impl.audio
|
||||
|
||||
import io.element.android.opusencoder.configuration.SampleRate as LibOpusOggSampleRate
|
||||
|
||||
data object SampleRate {
|
||||
const val hz = 48_000
|
||||
fun asEncoderModel() = LibOpusOggSampleRate.Rate48kHz
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.voicerecorder.impl.di
|
||||
|
||||
import android.media.AudioFormat
|
||||
import android.media.MediaRecorder
|
||||
import com.squareup.anvil.annotations.ContributesTo
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.voicerecorder.impl.audio.AudioConfig
|
||||
import io.element.android.libraries.voicerecorder.impl.audio.SampleRate
|
||||
import io.element.android.libraries.voicerecorder.impl.file.VoiceFileConfig
|
||||
import io.element.android.opusencoder.OggOpusEncoder
|
||||
|
||||
@Module
|
||||
@ContributesTo(RoomScope::class)
|
||||
object VoiceRecorderModule {
|
||||
@Provides
|
||||
fun provideAudioConfig(): AudioConfig {
|
||||
val sampleRate = SampleRate
|
||||
return AudioConfig(
|
||||
format = AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(sampleRate.hz)
|
||||
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
|
||||
.build(),
|
||||
bitRate = 24_000, // 24 kbps
|
||||
sampleRate = sampleRate,
|
||||
source = MediaRecorder.AudioSource.MIC,
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideVoiceFileConfig(): VoiceFileConfig =
|
||||
VoiceFileConfig(
|
||||
cacheSubdir = "voice_recordings",
|
||||
fileExt = "ogg",
|
||||
mimeType = "audio/ogg",
|
||||
)
|
||||
|
||||
@Provides
|
||||
fun provideOggOpusEncoder(): OggOpusEncoder = OggOpusEncoder.create()
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.voicerecorder.impl.file
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.core.hash.md5
|
||||
import io.element.android.libraries.di.CacheDirectory
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(RoomScope::class)
|
||||
class DefaultVoiceFileManager @Inject constructor(
|
||||
@CacheDirectory private val cacheDir: File,
|
||||
private val config: VoiceFileConfig,
|
||||
room: MatrixRoom,
|
||||
) : VoiceFileManager {
|
||||
|
||||
private val roomId: RoomId = room.roomId
|
||||
|
||||
override fun createFile(): File {
|
||||
val fileName = "${UUID.randomUUID()}.${config.fileExt}"
|
||||
val outputDirectory = File(cacheDir, config.cacheSubdir)
|
||||
val roomDir = File(outputDirectory, roomId.value.md5())
|
||||
.apply(File::mkdirs)
|
||||
return File(roomDir, fileName)
|
||||
}
|
||||
|
||||
override fun deleteFile(file: File) {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.voicerecorder.impl.file
|
||||
|
||||
/**
|
||||
* File configuration for voice recording.
|
||||
*
|
||||
* @property cacheSubdir the subdirectory in the cache dir to use.
|
||||
* @property fileExt the file extension for audio files.
|
||||
* @property mimeType the mime type of audio files.
|
||||
*/
|
||||
data class VoiceFileConfig(
|
||||
val cacheSubdir: String,
|
||||
val fileExt: String,
|
||||
val mimeType: String,
|
||||
)
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.voicerecorder.impl.file
|
||||
|
||||
import java.io.File
|
||||
|
||||
interface VoiceFileManager {
|
||||
fun createFile(): File
|
||||
|
||||
fun deleteFile(file: File)
|
||||
}
|
||||
|
|
@ -0,0 +1,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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.voicerecorder.impl.audio
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
class DecibelAudioLevelCalculatorTest {
|
||||
|
||||
@Test
|
||||
fun `given max values, it returns values within range`() {
|
||||
val calculator = DecibelAudioLevelCalculator()
|
||||
val buffer = ShortArray(100) { Short.MAX_VALUE }
|
||||
val level = calculator.calculateAudioLevel(buffer)
|
||||
assert(level in 0.0..1.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given mixed values, it returns values within range`() {
|
||||
val calculator = DecibelAudioLevelCalculator()
|
||||
val buffer = shortArrayOf(Short.MAX_VALUE, Short.MIN_VALUE, -1, 1)
|
||||
val level = calculator.calculateAudioLevel(buffer)
|
||||
assert(level in 0.0..1.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given min values, it returns values within range`() {
|
||||
val calculator = DecibelAudioLevelCalculator()
|
||||
val buffer = ShortArray(100) { Short.MIN_VALUE }
|
||||
val level = calculator.calculateAudioLevel(buffer)
|
||||
assert(level in 0.0..1.0)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.voicerecorder.test
|
||||
|
||||
import io.element.android.libraries.voicerecorder.impl.audio.AudioLevelCalculator
|
||||
import kotlin.math.abs
|
||||
|
||||
class FakeAudioLevelCalculator: AudioLevelCalculator {
|
||||
override fun calculateAudioLevel(buffer: ShortArray): Double {
|
||||
return buffer.map { abs(it.toDouble()) }.average() / Short.MAX_VALUE
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.voicerecorder.test
|
||||
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.voicerecorder.impl.audio.Audio
|
||||
import io.element.android.libraries.voicerecorder.impl.audio.AudioConfig
|
||||
import io.element.android.libraries.voicerecorder.impl.audio.AudioReader
|
||||
|
||||
class FakeAudioRecorderFactory(
|
||||
private val audio: List<Audio>
|
||||
): AudioReader.Factory {
|
||||
override fun create(config: AudioConfig, dispatchers: CoroutineDispatchers): AudioReader {
|
||||
return FakeAudioReader(dispatchers, audio)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.voicerecorder.test
|
||||
|
||||
import io.element.android.libraries.voicerecorder.impl.audio.Encoder
|
||||
import java.io.File
|
||||
|
||||
class FakeEncoder(
|
||||
private val fakeFileSystem: FakeFileSystem
|
||||
) : Encoder {
|
||||
private var curFile: File? = null
|
||||
override fun init(file: File) {
|
||||
curFile = file
|
||||
}
|
||||
|
||||
override fun encode(buffer: ShortArray, readSize: Int) {
|
||||
val file = curFile
|
||||
?: error("Encoder not initialized")
|
||||
|
||||
fakeFileSystem.appendToFile(file, buffer, readSize)
|
||||
}
|
||||
|
||||
override fun release() {
|
||||
curFile = null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.voicerecorder.test
|
||||
|
||||
import java.io.File
|
||||
|
||||
class FakeFileSystem {
|
||||
// Map of file to file content
|
||||
val files = mutableMapOf<File, String>()
|
||||
|
||||
fun createFile(file: File) {
|
||||
if(files.containsKey(file)) {
|
||||
return
|
||||
}
|
||||
|
||||
files[file] = ""
|
||||
}
|
||||
|
||||
fun appendToFile(file: File, buffer: ShortArray, readSize: Int) {
|
||||
val content = files[file]
|
||||
?: error("File ${file.path} does not exist")
|
||||
|
||||
files[file] = content + buffer.sliceArray(0 until readSize).contentToString()
|
||||
}
|
||||
|
||||
fun deleteFile(file: File) {
|
||||
files.remove(file)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.voicerecorder.test
|
||||
|
||||
import io.element.android.libraries.voicerecorder.impl.file.VoiceFileConfig
|
||||
import io.element.android.libraries.voicerecorder.impl.file.VoiceFileManager
|
||||
import java.io.File
|
||||
|
||||
class FakeVoiceFileManager(
|
||||
private val fakeFileSystem: FakeFileSystem,
|
||||
private val config: VoiceFileConfig,
|
||||
private val fileId: String,
|
||||
) : VoiceFileManager {
|
||||
override fun createFile(): File {
|
||||
val file = File("${config.cacheSubdir}/$fileId.${config.fileExt}")
|
||||
fakeFileSystem.createFile(file)
|
||||
return file
|
||||
}
|
||||
|
||||
override fun deleteFile(file: File) {
|
||||
fakeFileSystem.deleteFile(file)
|
||||
}
|
||||
}
|
||||
31
libraries/voicerecorder/test/build.gradle.kts
Normal file
31
libraries/voicerecorder/test/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue