diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index 8669738fc6..c1eb17dec1 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -187,7 +187,9 @@ class LoggedInFlowNode @AssistedInject constructor( ) : NavTarget @Parcelize - data object Settings : NavTarget + data class Settings( + val initialElement: PreferencesEntryPoint.InitialTarget = PreferencesEntryPoint.InitialTarget.Root + ) : NavTarget @Parcelize data object CreateRoom : NavTarget @@ -219,7 +221,7 @@ class LoggedInFlowNode @AssistedInject constructor( } override fun onSettingsClicked() { - backstack.push(NavTarget.Settings) + backstack.push(NavTarget.Settings()) } override fun onCreateRoomClicked() { @@ -252,11 +254,15 @@ class LoggedInFlowNode @AssistedInject constructor( override fun onForwardedToSingleRoom(roomId: RoomId) { coroutineScope.launch { attachRoom(roomId) } } + + override fun onOpenGlobalNotificationSettings() { + backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.NotificationSettings)) + } } val inputs = RoomFlowNode.Inputs(roomId = navTarget.roomId, initialElement = navTarget.initialElement) createNode(buildContext, plugins = listOf(inputs, callback)) } - NavTarget.Settings -> { + is NavTarget.Settings -> { val callback = object : PreferencesEntryPoint.Callback { override fun onOpenBugReport() { plugins().forEach { it.onOpenBugReport() } @@ -265,8 +271,14 @@ class LoggedInFlowNode @AssistedInject constructor( override fun onVerifyClicked() { backstack.push(NavTarget.VerifySession) } + + override fun onOpenRoomNotificationSettings(roomId: RoomId) { + backstack.push(NavTarget.Room(roomId, initialElement = RoomLoadedFlowNode.NavTarget.RoomNotificationSettings)) + } } - preferencesEntryPoint.nodeBuilder(this, buildContext) + val inputs = PreferencesEntryPoint.Params(navTarget.initialElement) + return preferencesEntryPoint.nodeBuilder(this, buildContext) + .params(inputs) .callback(callback) .build() } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt index 8ad9beba61..613ed650c8 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt @@ -75,6 +75,7 @@ class RoomLoadedFlowNode @AssistedInject constructor( interface Callback : Plugin { fun onForwardedToSingleRoom(roomId: RoomId) + fun onOpenGlobalNotificationSettings() } data class Inputs( @@ -128,6 +129,18 @@ class RoomLoadedFlowNode @AssistedInject constructor( } } + private fun createRoomDetailsNode(buildContext: BuildContext, initialTarget: RoomDetailsEntryPoint.InitialTarget): Node { + val callback = object : RoomDetailsEntryPoint.Callback { + override fun onOpenGlobalNotificationSettings() { + callbacks.forEach { it.onOpenGlobalNotificationSettings() } + } + } + return roomDetailsEntryPoint.nodeBuilder(this, buildContext) + .params(RoomDetailsEntryPoint.Params(initialTarget)) + .callback(callback) + .build() + } + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return when (navTarget) { NavTarget.Messages -> { @@ -147,12 +160,13 @@ class RoomLoadedFlowNode @AssistedInject constructor( messagesEntryPoint.createNode(this, buildContext, callback) } NavTarget.RoomDetails -> { - val inputs = RoomDetailsEntryPoint.Inputs(RoomDetailsEntryPoint.InitialTarget.RoomDetails) - roomDetailsEntryPoint.createNode(this, buildContext, inputs, emptyList()) + createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomDetails) } is NavTarget.RoomMemberDetails -> { - val inputs = RoomDetailsEntryPoint.Inputs(RoomDetailsEntryPoint.InitialTarget.RoomMemberDetails(navTarget.userId)) - roomDetailsEntryPoint.createNode(this, buildContext, inputs, emptyList()) + createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomMemberDetails(navTarget.userId)) + } + NavTarget.RoomNotificationSettings -> { + createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomNotificationSettings) } } } @@ -166,6 +180,9 @@ class RoomLoadedFlowNode @AssistedInject constructor( @Parcelize data class RoomMemberDetails(val userId: UserId) : NavTarget + + @Parcelize + data object RoomNotificationSettings : NavTarget } @Composable diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt index a25e4d8134..fd5de85b1d 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt @@ -71,14 +71,22 @@ class RoomFlowNodeTest { var nodeId: String? = null - override fun createNode( - parentNode: Node, - buildContext: BuildContext, - inputs: RoomDetailsEntryPoint.Inputs, - plugins: List - ): Node { - return node(buildContext) {}.also { - nodeId = it.id + override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): RoomDetailsEntryPoint.NodeBuilder { + return object : RoomDetailsEntryPoint.NodeBuilder { + + override fun params(params: RoomDetailsEntryPoint.Params): RoomDetailsEntryPoint.NodeBuilder { + return this + } + + override fun callback(callback: RoomDetailsEntryPoint.Callback): RoomDetailsEntryPoint.NodeBuilder { + return this + } + + override fun build(): Node { + return node(buildContext) {}.also { + nodeId = it.id + } + } } } } diff --git a/build.gradle.kts b/build.gradle.kts index f08c023b1d..970dfe8465 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -198,6 +198,8 @@ koverMerged { // We do not cover Nodes (normally covered by maestro, but code coverage is not computed with maestro) "*Node", "*Node$*", + // Exclude `:libraries:matrix:impl` module, it contains only wrappers to access the Rust Matrix SDK api, so it is not really relevant to unit test it: there is no logic to test. + "io.element.android.libraries.matrix.impl.*", ) ) } diff --git a/changelog.d/1596.feature b/changelog.d/1596.feature new file mode 100644 index 0000000000..5108d6008b --- /dev/null +++ b/changelog.d/1596.feature @@ -0,0 +1 @@ +Record and send voice messages \ No newline at end of file diff --git a/changelog.d/2084.feature b/changelog.d/2084.feature new file mode 100644 index 0000000000..ddb6425ea7 --- /dev/null +++ b/changelog.d/2084.feature @@ -0,0 +1 @@ +Receive and play a voice message diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index 956b80949a..db625e5b74 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -51,6 +51,8 @@ dependencies { implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.permissions.api) implementation(projects.libraries.preferences.api) + implementation(projects.libraries.voicerecorder.api) + implementation(projects.libraries.uiUtils) implementation(projects.features.networkmonitor.api) implementation(projects.services.analytics.api) implementation(libs.coil.compose) @@ -63,6 +65,7 @@ dependencies { implementation(libs.vanniktech.blurhash) implementation(libs.telephoto.zoomableimage) implementation(libs.matrix.emojibase.bindings) + implementation(libs.audiowaveform) testImplementation(libs.test.junit) testImplementation(libs.coroutines.test) @@ -80,6 +83,7 @@ dependencies { testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.textcomposer.test) + testImplementation(projects.libraries.voicerecorder.test) testImplementation(libs.test.mockk) ksp(libs.showkase.processor) diff --git a/features/messages/impl/src/main/AndroidManifest.xml b/features/messages/impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..a00e8e1873 --- /dev/null +++ b/features/messages/impl/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index 50b59afbcc..2263fe51cb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -28,9 +28,10 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode import io.element.android.features.messages.impl.attachments.Attachment -import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.mediaplayer.MediaPlayer import io.element.android.libraries.di.RoomScope import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId @@ -49,6 +50,7 @@ class MessagesNode @AssistedInject constructor( private val analyticsService: AnalyticsService, private val presenterFactory: MessagesPresenter.Factory, private val timelineItemPresenterFactories: TimelineItemPresenterFactories, + private val mediaPlayer: MediaPlayer, ) : Node(buildContext, plugins = plugins), MessagesNavigator { private val presenter = presenterFactory.create(this) @@ -71,6 +73,9 @@ class MessagesNode @AssistedInject constructor( lifecycle.subscribe( onCreate = { analyticsService.capture(room.toAnalyticsViewRoom()) + }, + onDestroy = { + mediaPlayer.close() } ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index 6125e920b8..adae018fee 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -54,8 +54,9 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter -import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerPresenter +import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.preferences.api.store.PreferencesStore @@ -63,6 +64,7 @@ import io.element.android.libraries.androidutils.clipboard.ClipboardHelper import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher @@ -101,6 +103,7 @@ class MessagesPresenter @AssistedInject constructor( private val preferencesStore: PreferencesStore, private val featureFlagsService: FeatureFlagService, @Assisted private val navigator: MessagesNavigator, + private val buildMeta: BuildMeta, ) : Presenter { @AssistedFactory @@ -203,6 +206,7 @@ class MessagesPresenter @AssistedInject constructor( enableTextFormatting = enableTextFormatting, enableVoiceMessages = enableVoiceMessages, enableInRoomCalls = enableInRoomCalls, + appName = buildMeta.applicationName, eventSink = { handleEvents(it) } ) } @@ -328,6 +332,7 @@ class MessagesPresenter @AssistedInject constructor( type = AttachmentThumbnailType.Location, ) is TimelineItemPollContent, // TODO Polls: handle reply to + is TimelineItemVoiceContent, // TODO Voice messages: handle reply to is TimelineItemTextBasedContent, is TimelineItemRedactedContent, is TimelineItemStateContent, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt index 3a0585f390..86784edc7a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt @@ -23,7 +23,7 @@ import io.element.android.features.messages.impl.timeline.TimelineState import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState -import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerState +import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState import io.element.android.libraries.architecture.Async import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage @@ -50,5 +50,6 @@ data class MessagesState( val enableTextFormatting: Boolean, val enableVoiceMessages: Boolean, val enableInRoomCalls: Boolean, + val appName: String, val eventSink: (MessagesEvents) -> Unit ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt index 249c4b487e..9d12207362 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -18,6 +18,7 @@ package io.element.android.features.messages.impl import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.messages.impl.actionlist.anActionListState +import io.element.android.features.messages.impl.messagecomposer.AttachmentsState import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState import io.element.android.features.messages.impl.timeline.aTimelineItemList import io.element.android.features.messages.impl.timeline.aTimelineState @@ -25,13 +26,14 @@ import io.element.android.features.messages.impl.timeline.components.customreact import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent -import io.element.android.features.messages.impl.voicemessages.aVoiceMessageComposerState +import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState import io.element.android.libraries.architecture.Async import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.wysiwyg.compose.RichTextEditorState +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf open class MessagesStateProvider : PreviewParameterProvider { @@ -47,6 +49,20 @@ open class MessagesStateProvider : PreviewParameterProvider { roomAvatar = Async.Uninitialized, ), aMessagesState().copy(composerState = aMessageComposerState().copy(showTextFormatting = true)), + aMessagesState().copy( + enableVoiceMessages = true, + voiceMessageComposerState = aVoiceMessageComposerState(showPermissionRationaleDialog = true), + ), + aMessagesState().copy( + composerState = aMessageComposerState().copy( + attachmentsState = AttachmentsState.Sending.Processing(persistentListOf()) + ), + ), + aMessagesState().copy( + composerState = aMessageComposerState().copy( + attachmentsState = AttachmentsState.Sending.Uploading(0.33f) + ), + ), ) } @@ -86,5 +102,6 @@ fun aMessagesState() = MessagesState( enableTextFormatting = true, enableVoiceMessages = true, enableInRoomCalls = true, + appName = "Element", eventSink = {} ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 5a7168e7ce..3ff07def41 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -63,6 +63,8 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuEvents import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMessageMenu import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerEvents +import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView import io.element.android.libraries.androidutils.ui.hideKeyboard import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule @@ -83,6 +85,7 @@ import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.designsystem.utils.LogCompositions +import io.element.android.libraries.designsystem.utils.OnLifecycleEvent import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState import io.element.android.libraries.matrix.api.core.UserId @@ -107,6 +110,10 @@ fun MessagesView( ) { LogCompositions(tag = "MessagesScreen", msg = "Root") + OnLifecycleEvent { _, event -> + state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.LifecycleEvent(event)) + } + AttachmentStateView( state = state.composerState.attachmentsState, onPreviewAttachments = onPreviewAttachments, @@ -306,6 +313,18 @@ private fun MessagesViewContent( enableTextFormatting = state.enableTextFormatting, ) + if (state.enableVoiceMessages && state.voiceMessageComposerState.showPermissionRationaleDialog) { + VoiceMessagePermissionRationaleDialog( + onContinue = { + state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.AcceptPermissionRationale) + }, + onDismiss = { + state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.DismissPermissionsRationale) + }, + appName = state.appName + ) + } + ExpandableBottomSheetScaffold( sheetDragHandle = if (state.composerState.showTextFormatting) { @Composable { BottomSheetDragHandle() } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt index c9e485b88a..cd11aa875b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt @@ -29,6 +29,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.model.event.canBeCopied import io.element.android.features.messages.impl.timeline.model.event.canReact import io.element.android.features.preferences.api.store.PreferencesStore @@ -131,6 +132,23 @@ class ActionListPresenter @Inject constructor( } } } + is TimelineItemVoiceContent -> { + buildList { + if (timelineItem.isRemote) { + add(TimelineItemAction.Reply) + add(TimelineItemAction.Forward) + } + if (isDeveloperModeEnabled) { + add(TimelineItemAction.ViewSource) + } + if (!timelineItem.isMine) { + add(TimelineItemAction.ReportContent) + } + if (timelineItem.isMine || userCanRedact) { + add(TimelineItemAction.Redact) + } + } + } else -> buildList { if (timelineItem.isRemote) { // Can only reply or forward messages already uploaded to the server diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt index 05db97d3be..c5e6e5facd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt @@ -66,6 +66,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatterImpl import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.AvatarSize @@ -237,6 +238,7 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif when (event.content) { is TimelineItemPollContent, // TODO Polls: handle summary + is TimelineItemVoiceContent, // TODO Voice messages: handle reply summary is TimelineItemTextBasedContent, is TimelineItemStateContent, is TimelineItemEncryptedContent, @@ -348,7 +350,7 @@ private fun EmojiReactionsRow( ) { // TODO use most recently used emojis here when available from the Rust SDK val defaultEmojis = sequenceOf( - "👍", "👎", "🔥", "❤️", "👏" + "👍️", "👎️", "🔥", "❤️", "👏" ) for (emoji in defaultEmojis) { val isHighlighted = highlightedEmojis.contains(emoji) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mediaplayer/MediaPlayer.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mediaplayer/MediaPlayer.kt new file mode 100644 index 0000000000..71acc9bb3c --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mediaplayer/MediaPlayer.kt @@ -0,0 +1,192 @@ +/* + * 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.features.messages.impl.mediaplayer + +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.matrix.api.core.EventId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * A media player for Element X. + */ +interface MediaPlayer : AutoCloseable { + + /** + * The current state of the player. + */ + val state: StateFlow + + /** + * Acquires control of the player and starts playing the given media. + */ + fun acquireControlAndPlay( + uri: String, + mediaId: String, + mimeType: String, + ) + + /** + * Plays the current media. + */ + fun play() + + /** + * Pauses the current media. + */ + fun pause() + + /** + * Seeks the current media to the given position. + */ + fun seekTo(positionMs: Long) + + /** + * Releases any resources associated with this player. + */ + override fun close() + + data class State( + /** + * Whether the player is currently playing. + */ + val isPlaying: Boolean, + /** + * The id of the media which is currently playing. + * + * NB: This is usually the string representation of the [EventId] of the event + * which contains the media. + */ + val mediaId: String?, + /** + * The current position of the player. + */ + val currentPosition: Long, + ) +} + +/** + * Default implementation of [MediaPlayer] backed by a [SimplePlayer]. + */ +@ContributesBinding(RoomScope::class) +@SingleIn(RoomScope::class) +class MediaPlayerImpl @Inject constructor( + private val player: SimplePlayer, +) : MediaPlayer { + + private val listener = object : SimplePlayer.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + _state.update { + it.copy( + currentPosition = player.currentPosition, + isPlaying = isPlaying, + ) + } + if (isPlaying) { + job = scope.launch { updateCurrentPosition() } + } else { + job?.cancel() + } + } + + override fun onMediaItemTransition(mediaItem: MediaItem?) { + _state.update { + it.copy( + currentPosition = player.currentPosition, + mediaId = mediaItem?.mediaId, + ) + } + } + } + + init { + player.addListener(listener) + } + + private val scope = CoroutineScope(Job() + Dispatchers.Main) + private var job: Job? = null + + private val _state = MutableStateFlow(MediaPlayer.State(false, null, 0L)) + + override val state: StateFlow = _state.asStateFlow() + + override fun acquireControlAndPlay(uri: String, mediaId: String, mimeType: String) { + player.clearMediaItems() + player.setMediaItem( + MediaItem.Builder() + .setUri(uri) + .setMediaId(mediaId) + .setMimeType(mimeType) + .build() + ) + player.prepare() + player.play() + } + + override fun play() { + if (player.playbackState == Player.STATE_ENDED) { + // There's a bug with some ogg files that somehow report to + // have no duration. + // With such files, once playback has ended once, calling + // player.seekTo(0) and then player.play() results in the + // player starting and stopping playing immediately effectively + // playing no sound. + // This is a workaround which will reload the media file. + player.getCurrentMediaItem()?.let { + player.setMediaItem(it) + player.prepare() + player.play() + } + } else { + player.play() + } + } + + override fun pause() { + player.pause() + } + + override fun seekTo(positionMs: Long) { + player.seekTo(positionMs) + } + + override fun close() { + player.release() + } + + private suspend fun updateCurrentPosition() { + while (true) { + if (!_state.value.isPlaying) return + delay(100) + _state.update { + it.copy(currentPosition = player.currentPosition) + } + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mediaplayer/SimplePlayer.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mediaplayer/SimplePlayer.kt new file mode 100644 index 0000000000..aeaa7bd69f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mediaplayer/SimplePlayer.kt @@ -0,0 +1,92 @@ +/* + * 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.features.messages.impl.mediaplayer + +import android.content.Context +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.di.RoomScope + +/** + * A subset of media3 [Player] that only exposes the few methods we need making it easier to mock. + */ +interface SimplePlayer { + fun addListener(listener: Listener) + val currentPosition: Long + val playbackState: Int + fun clearMediaItems() + fun setMediaItem(mediaItem: MediaItem) + fun getCurrentMediaItem(): MediaItem? + fun prepare() + fun play() + fun pause() + fun seekTo(positionMs: Long) + fun release() + interface Listener { + fun onIsPlayingChanged(isPlaying: Boolean) + fun onMediaItemTransition(mediaItem: MediaItem?) + } +} + +@ContributesTo(RoomScope::class) +@Module +object SimplePlayerModule { + @Provides + fun simplePlayerProvider( + @ApplicationContext context: Context, + ): SimplePlayer = SimplePlayerImpl(ExoPlayer.Builder(context).build()) +} + +/** + * Default implementation of [SimplePlayer] backed by a media3 [Player]. + */ +class SimplePlayerImpl( + private val p: Player +) : SimplePlayer { + override fun addListener(listener: SimplePlayer.Listener) { + p.addListener(object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) = listener.onIsPlayingChanged(isPlaying) + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) = listener.onMediaItemTransition(mediaItem) + }) + } + + override val currentPosition: Long + get() = p.currentPosition + override val playbackState: Int + get() = p.playbackState + + override fun clearMediaItems() = p.clearMediaItems() + + override fun setMediaItem(mediaItem: MediaItem) = p.setMediaItem(mediaItem) + + override fun getCurrentMediaItem(): MediaItem? = p.currentMediaItem + + override fun prepare() = p.prepare() + + override fun play() = p.play() + + override fun pause() = p.pause() + + override fun seekTo(positionMs: Long) = p.seekTo(positionMs) + + override fun release() = p.release() +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt index 150ae23f9b..a2d8d9c571 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt @@ -24,10 +24,10 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp -import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerEvents -import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerState -import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerStateProvider -import io.element.android.features.messages.impl.voicemessages.aVoiceMessageComposerState +import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerEvents +import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState +import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerStateProvider +import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.textcomposer.model.Message @@ -71,10 +71,18 @@ internal fun MessageComposerView( } } - fun onVoiceRecordButtonEvent(press: PressEvent) { + val onVoiceRecordButtonEvent = { press: PressEvent -> voiceMessageState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(press)) } + val onSendVoiceMessage = { + voiceMessageState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage) + } + + val onDeleteVoiceMessage = { + voiceMessageState.eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage) + } + TextComposer( modifier = modifier, state = state.richTextEditorState, @@ -89,7 +97,9 @@ internal fun MessageComposerView( onDismissTextFormatting = ::onDismissTextFormatting, enableTextFormatting = enableTextFormatting, enableVoiceMessages = enableVoiceMessages, - onVoiceRecordButtonEvent = ::onVoiceRecordButtonEvent, + onVoiceRecordButtonEvent = onVoiceRecordButtonEvent, + onSendVoiceMessage = onSendVoiceMessage, + onDeleteVoiceMessage = onDeleteVoiceMessage, onError = ::onError, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt index 1374f4aef4..2955c6783f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt @@ -139,7 +139,7 @@ fun aTimelineItemReactions( count: Int = 1, isHighlighted: Boolean = false, ): TimelineItemReactions { - val emojis = arrayOf("👍", "😀️", "😁️", "😆️", "😅️", "🤣️", "🥰️", "😇️", "😊️", "😉️", "🙃️", "🙂️", "😍️", "🤗️", "🤭️") + val emojis = arrayOf("👍️", "😀️", "😁️", "😆️", "😅️", "🤣️", "🥰️", "😇️", "😊️", "😉️", "🙃️", "🙂️", "😍️", "🤗️", "🤭️") return TimelineItemReactions( reactions = buildList { repeat(count) { index -> diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt new file mode 100644 index 0000000000..3de0734b61 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt @@ -0,0 +1,93 @@ +/* + * 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.features.messages.impl.timeline.components.customreaction + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import io.element.android.emojibasebindings.Emoji +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 + +@Composable +fun EmojiItem( + item: Emoji, + isSelected: Boolean, + onEmojiSelected: (Emoji) -> Unit, + modifier: Modifier = Modifier, +) { + val backgroundColor = if (isSelected) { + ElementTheme.colors.bgActionPrimaryRest + } else { + Color.Transparent + } + + Box( + modifier = modifier + .size(40.dp) + .background(backgroundColor, CircleShape) + .clickable( + enabled = true, + onClick = { onEmojiSelected(item) }, + indication = rememberRipple(bounded = false, radius = 20.dp), + interactionSource = remember { MutableInteractionSource() } + ), + contentAlignment = Alignment.Center + ) { + Text( + text = item.unicode, + style = ElementTheme.typography.fontHeadingSmRegular, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun EmojiItemPreview() = ElementPreview { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (isSelected in listOf(true, false)) { + EmojiItem( + item = Emoji( + hexcode = "", + label = "", + tags = null, + shortcodes = emptyList(), + unicode = "👍", + skins = null + ), + isSelected = isSelected, + onEmojiSelected = {}, + ) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPicker.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPicker.kt index ee9d4c819d..29d6b14e59 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPicker.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPicker.kt @@ -17,31 +17,22 @@ package io.element.android.features.messages.impl.timeline.components.customreaction import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.SecondaryTabRow import androidx.compose.material3.Tab import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -52,8 +43,6 @@ import io.element.android.emojibasebindings.EmojibaseStore import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Icon -import io.element.android.libraries.designsystem.theme.components.Text -import io.element.android.libraries.theme.ElementTheme import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.launch @@ -101,31 +90,12 @@ fun EmojiPicker( contentPadding = PaddingValues(vertical = 10.dp, horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - items(emojis, key = { it.unicode }) { item -> - val backgroundColor = if (selectedEmojis.contains(item.unicode)) { - ElementTheme.colors.bgActionPrimaryRest - } else { - Color.Transparent - } - - Box( - modifier = Modifier - .size(40.dp) - .background(backgroundColor, CircleShape) - .clickable( - enabled = true, - onClick = { onEmojiSelected(item) }, - indication = rememberRipple(bounded = false, radius = 20.dp), - interactionSource = remember { MutableInteractionSource() } - ), - contentAlignment = Alignment.Center - ) { - Text( - text = item.unicode, - style = ElementTheme.typography.fontHeadingSmRegular, - ) - } + EmojiItem( + item = item, + isSelected = selectedEmojis.contains(item.unicode), + onEmojiSelected = onEmojiSelected + ) } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt index dccc020e49..22d1d9cacd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt @@ -20,6 +20,8 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import io.element.android.features.messages.impl.timeline.TimelineEvents +import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories +import io.element.android.features.messages.impl.timeline.di.rememberPresenter import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent @@ -32,6 +34,9 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent +import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageState +import io.element.android.libraries.architecture.Presenter @Composable fun TimelineItemEventContentView( @@ -44,6 +49,7 @@ fun TimelineItemEventContentView( eventSink: (TimelineEvents) -> Unit, modifier: Modifier = Modifier ) { + val presenterFactories = LocalTimelineItemPresenterFactories.current when (content) { is TimelineItemEncryptedContent -> TimelineItemEncryptedView( content = content, @@ -100,5 +106,14 @@ fun TimelineItemEventContentView( eventSink = eventSink, modifier = modifier, ) + is TimelineItemVoiceContent -> { + val presenter: Presenter = presenterFactories.rememberPresenter(content) + TimelineItemVoiceView( + state = presenter.present(), + content = content, + extraPadding = extraPadding, + modifier = modifier + ) + } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt new file mode 100644 index 0000000000..bbcebd006d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt @@ -0,0 +1,235 @@ +/* + * 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.features.messages.impl.timeline.components.event + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import io.element.android.features.messages.impl.R +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContentProvider +import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageEvents +import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageState +import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageStateProvider +import io.element.android.features.messages.impl.voicemessages.timeline.WaveformProgressIndicator +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun TimelineItemVoiceView( + state: VoiceMessageState, + content: TimelineItemVoiceContent, + extraPadding: ExtraPadding, + modifier: Modifier = Modifier, +) { + fun playPause() { + state.eventSink(VoiceMessageEvents.PlayPause) + } + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + when (state.button) { + VoiceMessageState.Button.Play -> PlayButton(onClick = ::playPause) + VoiceMessageState.Button.Pause -> PauseButton(onClick = ::playPause) + VoiceMessageState.Button.Downloading -> ProgressButton() + VoiceMessageState.Button.Retry -> RetryButton(onClick = ::playPause) + VoiceMessageState.Button.Disabled -> DisabledPlayButton() + } + Spacer(Modifier.width(8.dp)) + Text( + text = state.time, + color = ElementTheme.materialColors.secondary, + style = ElementTheme.typography.fontBodySmMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.width(8.dp)) + WaveformProgressIndicator( + modifier = Modifier + .height(34.dp) + .weight(1f), + progress = state.progress, + amplitudes = content.waveform, + onSeek = { state.eventSink(VoiceMessageEvents.Seek(it)) } + ) + Spacer(Modifier.width(extraPadding.getDpSize())) + } +} + +@Composable +private fun PlayButton( + onClick: (() -> Unit) +) { + IconButton( + drawableRes = R.drawable.play, + contentDescription = stringResource(id = CommonStrings.a11y_play), + onClick = onClick + ) +} + +@Composable +private fun PauseButton( + onClick: (() -> Unit) +) { + IconButton( + drawableRes = R.drawable.pause, + contentDescription = stringResource(id = CommonStrings.a11y_play), + onClick = onClick + ) +} + +@Composable +private fun RetryButton( + onClick: (() -> Unit) +) { + IconButton( + drawableRes = R.drawable.retry, + contentDescription = stringResource(id = CommonStrings.action_retry), + onClick = onClick + ) +} + +@Composable +private fun ProgressButton() { + Button { + CircularProgressIndicator( + modifier = Modifier + .padding(2.dp) + .size(16.dp), + color = ElementTheme.colors.iconSecondary, + strokeWidth = 2.dp, + ) + } +} + +@Composable +private fun DisabledPlayButton() { + IconButton( + drawableRes = R.drawable.play, + contentDescription = null, + onClick = null, + ) +} + +@Composable +private fun IconButton( + @DrawableRes drawableRes: Int, + contentDescription: String?, + onClick: (() -> Unit)?, +) { + Button( + onClick = onClick, + ) { + Icon( + painter = painterResource(id = drawableRes), + contentDescription = contentDescription, + tint = ElementTheme.colors.iconSecondary, + modifier = Modifier.size(24.dp), + ) + } +} + +@Composable +private fun Button( + onClick: (() -> Unit)? = null, + content: @Composable () -> Unit, +) { + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(ElementTheme.materialColors.background) + .let { + if (onClick != null) it.clickable(onClick = onClick) else it + }, + contentAlignment = Alignment.Center, + ) { + content() + } +} + +open class TimelineItemVoiceViewParametersProvider : PreviewParameterProvider { + private val voiceMessageStateProvider = VoiceMessageStateProvider() + private val timelineItemVoiceContentProvider = TimelineItemVoiceContentProvider() + override val values: Sequence + get() = timelineItemVoiceContentProvider.values.flatMap { content -> + voiceMessageStateProvider.values.map { state -> + TimelineItemVoiceViewParameters( + state = state, + content = content, + ) + } + } +} + +data class TimelineItemVoiceViewParameters( + val state: VoiceMessageState, + val content: TimelineItemVoiceContent, +) + +@PreviewsDayNight +@Composable +internal fun TimelineItemVoiceViewPreview( + @PreviewParameter(TimelineItemVoiceViewParametersProvider::class) timelineItemVoiceViewParameters: TimelineItemVoiceViewParameters, +) = ElementPreview { + TimelineItemVoiceView( + state = timelineItemVoiceViewParameters.state, + content = timelineItemVoiceViewParameters.content, + extraPadding = noExtraPadding, + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemVoiceViewUnifiedPreview() = ElementPreview { + val timelineItemVoiceViewParametersProvider = TimelineItemVoiceViewParametersProvider() + Column { + timelineItemVoiceViewParametersProvider.values.forEach { + TimelineItemVoiceView( + state = it.state, + content = it.content, + extraPadding = noExtraPadding, + ) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt index 55e889f496..b5a3c9545e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt @@ -52,7 +52,7 @@ class TimelineItemContentFactory @Inject constructor( is FailedToParseStateContent -> failedToParseStateFactory.create(itemContent) is MessageContent -> { val senderDisplayName = (eventTimelineItem.senderProfile as? ProfileTimelineDetails.Ready)?.displayName ?: eventTimelineItem.sender.value - messageFactory.create(itemContent, senderDisplayName) + messageFactory.create(itemContent, senderDisplayName, eventTimelineItem.eventId) } is ProfileChangeContent -> profileChangeFactory.create(eventTimelineItem) is RedactedContent -> redactedMessageFactory.create(itemContent) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt index f39c1e0989..4f78c38393 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt @@ -26,10 +26,14 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemNoticeContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractor import io.element.android.features.messages.impl.timeline.util.toHtmlDocument import io.element.android.libraries.androidutils.filesize.FileSizeFormatter import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType @@ -41,15 +45,18 @@ import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageT import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType import io.element.android.libraries.matrix.api.timeline.item.event.UnknownMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import java.time.Duration import javax.inject.Inject class TimelineItemContentMessageFactory @Inject constructor( private val fileSizeFormatter: FileSizeFormatter, private val fileExtensionExtractor: FileExtensionExtractor, + private val featureFlagService: FeatureFlagService, ) { - fun create(content: MessageContent, senderDisplayName: String): TimelineItemEventContent { + suspend fun create(content: MessageContent, senderDisplayName: String, eventId: EventId?): TimelineItemEventContent { return when (val messageType = content.type) { is EmoteMessageType -> TimelineItemEmoteContent( body = "* $senderDisplayName ${messageType.body}", @@ -103,14 +110,24 @@ class TimelineItemContentMessageFactory @Inject constructor( fileExtension = fileExtensionExtractor.extractFromName(messageType.body) ) } - is AudioMessageType -> TimelineItemAudioContent( - body = messageType.body, - mediaSource = messageType.source, - duration = messageType.info?.duration ?: Duration.ZERO, - mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream, - formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0), - fileExtension = fileExtensionExtractor.extractFromName(messageType.body), - ) + is AudioMessageType -> when { + featureFlagService.isFeatureEnabled(FeatureFlags.VoiceMessages) && messageType.isVoiceMessage -> TimelineItemVoiceContent( + eventId = eventId, + body = messageType.body, + mediaSource = messageType.source, + duration = messageType.info?.duration ?: Duration.ZERO, + mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream, + waveform = messageType.details?.waveform?.toImmutableList() ?: persistentListOf(), + ) + else -> TimelineItemAudioContent( + body = messageType.body, + mediaSource = messageType.source, + duration = messageType.info?.duration ?: Duration.ZERO, + mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream, + formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0), + fileExtension = fileExtensionExtractor.extractFromName(messageType.body), + ) + } is FileMessageType -> { val fileExtension = fileExtensionExtractor.extractFromName(messageType.body) TimelineItemFileContent( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt index 844942002a..51ab200c48 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt @@ -30,6 +30,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent @@ -58,6 +59,7 @@ internal fun TimelineItem.Event.canBeGrouped(): Boolean { is TimelineItemAudioContent, is TimelineItemLocationContent, is TimelineItemPollContent, + is TimelineItemVoiceContent, TimelineItemRedactedContent, TimelineItemUnknownContent -> false is TimelineItemProfileChangeContent, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt index ef31d6249c..eda9bcbdb4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt @@ -40,6 +40,7 @@ fun TimelineItemEventContent.canBeCopied(): Boolean = */ fun TimelineItemEventContent.canBeRepliedTo(): Boolean = when (this) { + is TimelineItemVoiceContent, // TODO Voice messages: swipe to reply disabled for now to avoid conflict with audio scrubbing. is TimelineItemRedactedContent, is TimelineItemStateContent, is TimelineItemPollContent -> false @@ -58,6 +59,7 @@ fun TimelineItemEventContent.canReact(): Boolean = is TimelineItemImageContent, is TimelineItemLocationContent, is TimelineItemPollContent, + is TimelineItemVoiceContent, is TimelineItemVideoContent -> true is TimelineItemStateContent, is TimelineItemRedactedContent, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContent.kt new file mode 100644 index 0000000000..ec4647b7bd --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContent.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 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.features.messages.impl.timeline.model.event + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.media.MediaSource +import kotlinx.collections.immutable.ImmutableList +import java.time.Duration + +data class TimelineItemVoiceContent( + val eventId: EventId?, + val body: String, + val duration: Duration, + val mediaSource: MediaSource, + val mimeType: String, + val waveform: ImmutableList, +) : TimelineItemEventContent { + override val type: String = "TimelineItemAudioContent" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContentProvider.kt new file mode 100644 index 0000000000..8c9ac1f21c --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContentProvider.kt @@ -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.features.messages.impl.timeline.model.event + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.media.MediaSource +import kotlinx.collections.immutable.toPersistentList +import java.time.Duration + +open class TimelineItemVoiceContentProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aTimelineItemVoiceContent( + durationMs = 1, + waveform = listOf(), + ), + aTimelineItemVoiceContent( + durationMs = 10_000, + waveform = listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0), + ), + aTimelineItemVoiceContent( + durationMs = 1_800_000, // 30 minutes + waveform = List(1024) { it }, + ), + ) +} + +fun aTimelineItemVoiceContent( + eventId: String? = "\$anEventId", + body: String = "body doesn't really matter for a voice message", + durationMs: Long = 61_000, + contentUri: String = "mxc://matrix.org/1234567890abcdefg", + mimeType: String = MimeTypes.Ogg, + waveform: List = listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0), +) = TimelineItemVoiceContent( + eventId = eventId?.let { EventId(it) }, + body = body, + duration = Duration.ofMillis(durationMs), + mediaSource = MediaSource(contentUri), + mimeType = mimeType, + waveform = waveform.toPersistentList(), +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatterImpl.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatterImpl.kt index 03a2063b36..6b3ed3d65e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatterImpl.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatterImpl.kt @@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.RoomScope import io.element.android.libraries.ui.strings.CommonStrings @@ -49,6 +50,7 @@ class MessageSummaryFormatterImpl @Inject constructor( is TimelineItemEncryptedContent -> context.getString(CommonStrings.common_unable_to_decrypt) is TimelineItemRedactedContent -> context.getString(CommonStrings.common_message_removed) is TimelineItemPollContent -> event.content.question + is TimelineItemVoiceContent -> context.getString(CommonStrings.common_voice_message) is TimelineItemUnknownContent -> context.getString(CommonStrings.common_unsupported_event) is TimelineItemImageContent -> context.getString(CommonStrings.common_image) is TimelineItemVideoContent -> context.getString(CommonStrings.common_video) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageComposerPresenter.kt deleted file mode 100644 index 106125934b..0000000000 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageComposerPresenter.kt +++ /dev/null @@ -1,64 +0,0 @@ -/* - * 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.features.messages.impl.voicemessages - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.di.RoomScope -import io.element.android.libraries.di.SingleIn -import io.element.android.libraries.textcomposer.model.PressEvent -import io.element.android.libraries.textcomposer.model.VoiceMessageState -import javax.inject.Inject - -@SingleIn(RoomScope::class) -class VoiceMessageComposerPresenter @Inject constructor() : Presenter { - @Composable - override fun present(): VoiceMessageComposerState { - var voiceMessageState by remember { mutableStateOf(VoiceMessageState.Idle) } - - fun onRecordButtonPress(event: VoiceMessageComposerEvents.RecordButtonEvent) = when(event.pressEvent) { - PressEvent.PressStart -> { - // TODO start the recording - voiceMessageState = VoiceMessageState.Recording - } - PressEvent.LongPressEnd -> { - // TODO finish the recording - voiceMessageState = VoiceMessageState.Idle - } - PressEvent.Tapped -> { - // TODO discard the recording and show the 'hold to record' tooltip - voiceMessageState = VoiceMessageState.Idle - } - } - - - fun handleEvents(event: VoiceMessageComposerEvents) { - when (event) { - is VoiceMessageComposerEvents.RecordButtonEvent -> onRecordButtonPress(event) - } - } - - return VoiceMessageComposerState( - voiceMessageState = voiceMessageState, - eventSink = { handleEvents(it) } - ) - } -} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageException.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageException.kt new file mode 100644 index 0000000000..2020b687ae --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageException.kt @@ -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.features.messages.impl.voicemessages + +internal sealed class VoiceMessageException : Exception() { + data class FileException( + override val message: String?, override val cause: Throwable? = null + ) : VoiceMessageException() + data class PermissionMissing( + override val message: String?, override val cause: Throwable? + ) : VoiceMessageException() +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerEvents.kt new file mode 100644 index 0000000000..384e249611 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerEvents.kt @@ -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. + */ + +package io.element.android.features.messages.impl.voicemessages.composer + +import androidx.lifecycle.Lifecycle +import io.element.android.libraries.textcomposer.model.PressEvent + +sealed interface VoiceMessageComposerEvents { + data class RecordButtonEvent( + val pressEvent: PressEvent + ): VoiceMessageComposerEvents + data object SendVoiceMessage: VoiceMessageComposerEvents + data object DeleteVoiceMessage: VoiceMessageComposerEvents + data object AcceptPermissionRationale: VoiceMessageComposerEvents + data object DismissPermissionsRationale: VoiceMessageComposerEvents + data class LifecycleEvent(val event: Lifecycle.Event): VoiceMessageComposerEvents +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenter.kt new file mode 100644 index 0000000000..cbc5b7332f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenter.kt @@ -0,0 +1,199 @@ +/* + * 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.features.messages.impl.voicemessages.composer + +import android.Manifest +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.core.net.toUri +import androidx.lifecycle.Lifecycle +import io.element.android.features.messages.impl.voicemessages.VoiceMessageException +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.mediaupload.api.MediaSender +import io.element.android.libraries.permissions.api.PermissionsEvents +import io.element.android.libraries.permissions.api.PermissionsPresenter +import io.element.android.libraries.textcomposer.model.PressEvent +import io.element.android.libraries.textcomposer.model.VoiceMessageState +import io.element.android.libraries.voicerecorder.api.VoiceRecorder +import io.element.android.libraries.voicerecorder.api.VoiceRecorderState +import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber +import java.io.File +import javax.inject.Inject + +@SingleIn(RoomScope::class) +class VoiceMessageComposerPresenter @Inject constructor( + private val appCoroutineScope: CoroutineScope, + private val voiceRecorder: VoiceRecorder, + private val analyticsService: AnalyticsService, + private val mediaSender: MediaSender, + permissionsPresenterFactory: PermissionsPresenter.Factory +) : Presenter { + private val permissionsPresenter = permissionsPresenterFactory.create(Manifest.permission.RECORD_AUDIO) + + @Composable + override fun present(): VoiceMessageComposerState { + val localCoroutineScope = rememberCoroutineScope() + val recorderState by voiceRecorder.state.collectAsState(initial = VoiceRecorderState.Idle) + + val permissionState = permissionsPresenter.present() + var isSending by remember { mutableStateOf(false) } + + val onLifecycleEvent = { event: Lifecycle.Event -> + when (event) { + Lifecycle.Event.ON_PAUSE -> { + appCoroutineScope.finishRecording() + } + Lifecycle.Event.ON_DESTROY -> { + appCoroutineScope.cancelRecording() + } + else -> {} + } + } + + val onRecordButtonPress = { event: VoiceMessageComposerEvents.RecordButtonEvent -> + val permissionGranted = permissionState.permissionGranted + when (event.pressEvent) { + PressEvent.PressStart -> { + Timber.v("Voice message record button pressed") + when { + permissionGranted -> { + localCoroutineScope.startRecording() + } + else -> { + Timber.i("Voice message permission needed") + permissionState.eventSink(PermissionsEvents.RequestPermissions) + } + } + } + PressEvent.LongPressEnd -> { + Timber.v("Voice message record button released") + localCoroutineScope.finishRecording() + } + PressEvent.Tapped -> { + Timber.v("Voice message record button tapped") + localCoroutineScope.cancelRecording() + } + } + } + + val onAcceptPermissionsRationale = { + permissionState.eventSink(PermissionsEvents.OpenSystemSettingAndCloseDialog) + } + + val onDismissPermissionsRationale = { + permissionState.eventSink(PermissionsEvents.CloseDialog) + } + + val onSendButtonPress = lambda@{ + val finishedState = recorderState as? VoiceRecorderState.Finished + if (finishedState == null) { + val exception = VoiceMessageException.FileException("No file to send") + analyticsService.trackError(exception) + Timber.e(exception) + return@lambda + } + if (isSending) { + return@lambda + } + isSending = true + appCoroutineScope.sendMessage( + file = finishedState.file, + mimeType = finishedState.mimeType, + ).invokeOnCompletion { + isSending = false + } + } + + val handleEvents: (VoiceMessageComposerEvents) -> Unit = { event -> + when (event) { + is VoiceMessageComposerEvents.RecordButtonEvent -> onRecordButtonPress(event) + is VoiceMessageComposerEvents.SendVoiceMessage -> localCoroutineScope.launch { + onSendButtonPress() + } + VoiceMessageComposerEvents.DeleteVoiceMessage -> localCoroutineScope.deleteRecording() + VoiceMessageComposerEvents.DismissPermissionsRationale -> onDismissPermissionsRationale() + VoiceMessageComposerEvents.AcceptPermissionRationale -> onAcceptPermissionsRationale() + is VoiceMessageComposerEvents.LifecycleEvent -> onLifecycleEvent(event.event) + } + } + + return VoiceMessageComposerState( + voiceMessageState = when (val state = recorderState) { + is VoiceRecorderState.Recording -> VoiceMessageState.Recording( + duration = state.elapsedTime, + level = state.level + ) + is VoiceRecorderState.Finished -> if (isSending) { + VoiceMessageState.Sending + } else { + VoiceMessageState.Preview + } + else -> VoiceMessageState.Idle + }, + showPermissionRationaleDialog = permissionState.showDialog, + eventSink = handleEvents, + ) + } + + private fun CoroutineScope.startRecording() = launch { + try { + voiceRecorder.startRecord() + } catch (e: SecurityException) { + Timber.e(e, "Voice message error") + analyticsService.trackError(VoiceMessageException.PermissionMissing("Expected permission to record but none", e)) + } + } + + private fun CoroutineScope.finishRecording() = launch { + voiceRecorder.stopRecord() + } + + private fun CoroutineScope.cancelRecording() = launch { + voiceRecorder.stopRecord(cancelled = true) + } + + private fun CoroutineScope.deleteRecording() = launch { + voiceRecorder.deleteRecording() + } + + private fun CoroutineScope.sendMessage( + file: File, mimeType: String, + ) = launch { + val result = mediaSender.sendVoiceMessage( + uri = file.toUri(), + mimeType = mimeType, + waveForm = emptyList(), // TODO generate waveform + ) + + if (result.isFailure) { + Timber.e(result.exceptionOrNull(), "Voice message error") + return@launch + } + + voiceRecorder.deleteRecording() + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageComposerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerState.kt similarity index 87% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageComposerState.kt rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerState.kt index bacbe76324..fc7f0ad15f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageComposerState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerState.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.features.messages.impl.voicemessages +package io.element.android.features.messages.impl.voicemessages.composer import androidx.compose.runtime.Stable import io.element.android.libraries.textcomposer.model.VoiceMessageState @@ -22,6 +22,7 @@ import io.element.android.libraries.textcomposer.model.VoiceMessageState @Stable data class VoiceMessageComposerState( val voiceMessageState: VoiceMessageState, + val showPermissionRationaleDialog: Boolean, val eventSink: (VoiceMessageComposerEvents) -> Unit, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageComposerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerStateProvider.kt similarity index 80% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageComposerStateProvider.kt rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerStateProvider.kt index 63b59596c0..01414a6dda 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageComposerStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerStateProvider.kt @@ -14,21 +14,24 @@ * limitations under the License. */ -package io.element.android.features.messages.impl.voicemessages +package io.element.android.features.messages.impl.voicemessages.composer import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.textcomposer.model.VoiceMessageState +import kotlin.time.Duration.Companion.seconds internal open class VoiceMessageComposerStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( - aVoiceMessageComposerState(voiceMessageState = VoiceMessageState.Recording), + aVoiceMessageComposerState(voiceMessageState = VoiceMessageState.Recording(duration = 61.seconds, level = 0.5)), ) } internal fun aVoiceMessageComposerState( voiceMessageState: VoiceMessageState = VoiceMessageState.Idle, + showPermissionRationaleDialog: Boolean = false, ) = VoiceMessageComposerState( voiceMessageState = voiceMessageState, + showPermissionRationaleDialog = showPermissionRationaleDialog, eventSink = {}, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessagePermissionRationaleDialog.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessagePermissionRationaleDialog.kt new file mode 100644 index 0000000000..9898aba95a --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessagePermissionRationaleDialog.kt @@ -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.features.messages.impl.voicemessages.composer + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +internal fun VoiceMessagePermissionRationaleDialog( + onContinue: () -> Unit, + onDismiss: () -> Unit, + appName: String, +) { + ConfirmationDialog( + content = stringResource(CommonStrings.error_missing_microphone_voice_rationale_android, appName), + onSubmitClicked = onContinue, + onDismiss = onDismiss, + submitText = stringResource(CommonStrings.action_continue), + cancelText = stringResource(CommonStrings.action_cancel), + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageCache.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageCache.kt new file mode 100644 index 0000000000..8ced58132e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageCache.kt @@ -0,0 +1,123 @@ +/* + * 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.features.messages.impl.voicemessages.timeline + +import com.squareup.anvil.annotations.ContributesBinding +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.CacheDirectory +import java.io.File + +/** + * Manages the local disk cache for a voice message. + */ +interface VoiceMessageCache { + + /** + * Factory for [VoiceMessageCache]. + */ + fun interface Factory { + /** + * Creates a [VoiceMessageCache] for the given Matrix Content (mxc://) URI. + * + * @param mxcUri the Matrix Content (mxc://) URI of the voice message. + */ + fun create(mxcUri: String): VoiceMessageCache + } + + /** + * The file path of the voice message in the cache directory. + * NB: This doesn't necessarily mean that the file exists. + * + * @return the file path of the voice message in the cache directory. + */ + val cachePath: String + + /** + * Checks if the voice message is in the cache directory. + * + * @return true if the voice message is in the cache directory. + */ + fun isInCache(): Boolean + + /** + * Moves the file to the voice cache directory. + * + * @return true if the file was successfully moved. + */ + fun moveToCache(file: File): Boolean +} + +/** + * Default implementation of [VoiceMessageCache]. + * + * NB: All methods will throw an [IllegalStateException] if the mxcUri is invalid. + * + * @param cacheDir the application's cache directory. + * @param mxcUri the Matrix Content (mxc://) URI of the voice message. + */ +class VoiceMessageCacheImpl @AssistedInject constructor( + @CacheDirectory private val cacheDir: File, + @Assisted private val mxcUri: String, +) : VoiceMessageCache { + + @ContributesBinding(AppScope::class) + @AssistedFactory + fun interface Factory : VoiceMessageCache.Factory { + override fun create(mxcUri: String): VoiceMessageCacheImpl + } + + override val cachePath: String = "${cacheDir.path}/$CACHE_VOICE_SUBDIR/${mxcUri2FilePath(mxcUri)}" + + override fun isInCache(): Boolean = File(cachePath).exists() + + override fun moveToCache(file: File): Boolean { + val dest = File(cachePath).apply { parentFile?.mkdirs() } + return file.renameTo(dest) + } +} + +/** + * Subdirectory of the application's cache directory where voice messages are stored. + */ +private const val CACHE_VOICE_SUBDIR = "temp/voice" + +/** + * Regex to match a Matrix Content (mxc://) URI. + * + * See: https://spec.matrix.org/v1.8/client-server-api/#matrix-content-mxc-uris + */ +private val mxcRegex = Regex("""^mxc:\/\/([^\/]+)\/([^\/]+)$""") + +/** + * Sanitizes an mxcUri to be used as a relative file path. + * + * @param mxcUri the Matrix Content (mxc://) URI of the voice message. + * @return the relative file path as "/". + * @throws IllegalStateException if the mxcUri is invalid. + */ +private fun mxcUri2FilePath(mxcUri: String): String = checkNotNull(mxcRegex.matchEntire(mxcUri)) { + "mxcUri2FilePath: Invalid mxcUri: $mxcUri" +}.let { match -> + buildString { + append(match.groupValues[1]) + append("/") + append(match.groupValues[2]) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageEvents.kt new file mode 100644 index 0000000000..3f2aa70f5d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageEvents.kt @@ -0,0 +1,22 @@ +/* + * 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.features.messages.impl.voicemessages.timeline + +sealed interface VoiceMessageEvents { + data object PlayPause : VoiceMessageEvents + data class Seek(val percentage: Float) : VoiceMessageEvents +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePlayer.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePlayer.kt new file mode 100644 index 0000000000..a2d4f0b655 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePlayer.kt @@ -0,0 +1,162 @@ +/* + * 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.features.messages.impl.voicemessages.timeline + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.messages.impl.mediaplayer.MediaPlayer +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.EventId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +/** + * A media player specialized in playing a single voice message. + */ +interface VoiceMessagePlayer { + + fun interface Factory { + + /** + * Creates a [VoiceMessagePlayer]. + * + * NB: Different voice messages can use the same content uri (e.g. in case of + * a forward of a voice message), + * therefore the media uri is not enough to uniquely identify a voice message. + * This is why we must provide the eventId as well. + * + * @param eventId The id of the voice message event. If null, a dummy + * player is returned. + * @param mediaPath The path to the voice message's media file. + */ + fun create(eventId: EventId?, mediaPath: String): VoiceMessagePlayer + } + + /** + * The current state of this player. + */ + val state: Flow + + /** + * Start playing from the beginning acquiring control of the + * underlying [MediaPlayer]. + */ + fun acquireControlAndPlay() + + /** + * Start playing from the current position. + */ + fun play() + + /** + * Pause playback. + */ + fun pause() + + /** + * Seek to a specific position. + * + * @param positionMs The position in milliseconds. + */ + fun seekTo(positionMs: Long) + + data class State( + /** + * Whether this player is currently playing. + */ + val isPlaying: Boolean, + /** + * Whether this player has control of the underlying [MediaPlayer]. + */ + val isMyMedia: Boolean, + /** + * The elapsed time of this player in milliseconds. + */ + val currentPosition: Long, + ) +} + +/** + * An implementation of [VoiceMessagePlayer] which is backed by a [MediaPlayer] + * usually shared among different [VoiceMessagePlayer] instances. + * + * @param mediaPlayer The [MediaPlayer] to use. + * @param eventId The id of the voice message event. If null, the player will behave as no-op. + * @param mediaPath The path to the voice message's media file. + */ +class VoiceMessagePlayerImpl( + private val mediaPlayer: MediaPlayer, + private val eventId: EventId?, + private val mediaPath: String, +) : VoiceMessagePlayer { + + @ContributesBinding(RoomScope::class) // Scoped types can't use @AssistedInject. + class Factory @Inject constructor( + private val mediaPlayer: MediaPlayer, + ) : VoiceMessagePlayer.Factory { + override fun create(eventId: EventId?, mediaPath: String): VoiceMessagePlayerImpl { + return VoiceMessagePlayerImpl( + mediaPlayer = mediaPlayer, + eventId = eventId, + mediaPath = mediaPath, + ) + } + } + + override val state: Flow = mediaPlayer.state.map { state -> + VoiceMessagePlayer.State( + isPlaying = state.mediaId.isMyTrack() && state.isPlaying, + isMyMedia = state.mediaId.isMyTrack(), + currentPosition = if (state.mediaId.isMyTrack()) state.currentPosition else 0L + ) + }.distinctUntilChanged() + + override fun acquireControlAndPlay() { + eventId?.let { eventId -> + mediaPlayer.acquireControlAndPlay( + uri = mediaPath, + mediaId = eventId.value, + mimeType = "audio/ogg" // Files in the voice cache have no extension so we need to set the mime type manually. + ) + } + } + + override fun play() { + ifInControl { + mediaPlayer.play() + } + } + + override fun pause() { + ifInControl { + mediaPlayer.pause() + } + } + + override fun seekTo(positionMs: Long) { + ifInControl { + mediaPlayer.seekTo(positionMs) + } + } + + private fun String?.isMyTrack(): Boolean = if (eventId == null) false else this == eventId.value + + private inline fun ifInControl(block: () -> Unit) { + if (mediaPlayer.state.value.mediaId.isMyTrack()) block() + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePresenter.kt new file mode 100644 index 0000000000..f94176b601 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePresenter.kt @@ -0,0 +1,151 @@ +/* + * 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.features.messages.impl.voicemessages.timeline + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import com.squareup.anvil.annotations.ContributesTo +import dagger.Binds +import dagger.Module +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.multibindings.IntoMap +import io.element.android.features.messages.impl.timeline.di.TimelineItemEventContentKey +import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactory +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runUpdatingState +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.media.MatrixMediaLoader +import io.element.android.libraries.matrix.api.media.MediaFile +import io.element.android.libraries.matrix.api.media.toFile +import io.element.android.libraries.ui.utils.time.formatShort +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.milliseconds + +@Module +@ContributesTo(RoomScope::class) +interface VoiceMessagePresenterModule { + @Binds + @IntoMap + @TimelineItemEventContentKey(TimelineItemVoiceContent::class) + fun bindVoiceMessagePresenterFactory(factory: VoiceMessagePresenter.Factory): TimelineItemPresenterFactory<*, *> +} + +class VoiceMessagePresenter @AssistedInject constructor( + private val mediaLoader: MatrixMediaLoader, + voiceMessagePlayerFactory: VoiceMessagePlayer.Factory, + voiceMessageCacheFactory: VoiceMessageCache.Factory, + @Assisted private val content: TimelineItemVoiceContent, +) : Presenter { + + @AssistedFactory + fun interface Factory : TimelineItemPresenterFactory { + override fun create(content: TimelineItemVoiceContent): VoiceMessagePresenter + } + + private val voiceCache = voiceMessageCacheFactory.create(mxcUri = content.mediaSource.url) + + private val player = voiceMessagePlayerFactory.create( + eventId = content.eventId, + mediaPath = voiceCache.cachePath + ) + + @Composable + override fun present(): VoiceMessageState { + + val scope = rememberCoroutineScope() + + val playerState by player.state.collectAsState(VoiceMessagePlayer.State(isPlaying = false, isMyMedia = false, currentPosition = 0L)) + val mediaFile = remember { mutableStateOf>(Async.Uninitialized) } + + val button by remember { + derivedStateOf { + when { + content.eventId == null -> VoiceMessageState.Button.Disabled + playerState.isPlaying -> VoiceMessageState.Button.Pause + mediaFile.value is Async.Loading -> VoiceMessageState.Button.Downloading + mediaFile.value is Async.Failure -> VoiceMessageState.Button.Retry + else -> VoiceMessageState.Button.Play + } + } + } + val progress by remember { + derivedStateOf { if (playerState.isMyMedia) playerState.currentPosition / content.duration.toMillis().toFloat() else 0f } + } + val time by remember { + derivedStateOf { + val time = if (playerState.isMyMedia) playerState.currentPosition else content.duration.toMillis() + time.milliseconds.formatShort() + } + } + + suspend fun downloadCacheAndPlay() { + mediaFile.runUpdatingState { + mediaLoader.downloadMediaFile( + source = content.mediaSource, + mimeType = content.mimeType, + body = content.body, + ).mapCatching { + if (voiceCache.moveToCache(it.toFile())) { + player.acquireControlAndPlay() + it + } else { + error("Failed to move file to cache.") + } + } + } + } + + fun eventSink(event: VoiceMessageEvents) { + when (event) { + is VoiceMessageEvents.PlayPause -> { + if (playerState.isMyMedia) { + if (playerState.isPlaying) { + player.pause() + } else { + player.play() + } + } else { + if (voiceCache.isInCache()) { + player.acquireControlAndPlay() + } else { + scope.launch { downloadCacheAndPlay() } + } + } + } + is VoiceMessageEvents.Seek -> { + player.seekTo((event.percentage * content.duration.toMillis()).toLong()) + } + } + } + + return VoiceMessageState( + button = button, + progress = progress, + time = time, + eventSink = { eventSink(it) }, + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageState.kt new file mode 100644 index 0000000000..093d5336fd --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageState.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.impl.voicemessages.timeline + +data class VoiceMessageState( + val button: Button, + val progress: Float, + val time: String, + val eventSink: (event: VoiceMessageEvents) -> Unit, +) { + enum class Button { + Play, + Pause, + Downloading, + Retry, + Disabled, + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageStateProvider.kt new file mode 100644 index 0000000000..e09c0a0449 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageStateProvider.kt @@ -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.features.messages.impl.voicemessages.timeline + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +open class VoiceMessageStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + VoiceMessageState( + VoiceMessageState.Button.Downloading, + progress = 0f, + time = "0:00", + eventSink = {}, + ), + VoiceMessageState( + VoiceMessageState.Button.Retry, + progress = 0.5f, + time = "0:01", + eventSink = {} + ), + VoiceMessageState( + VoiceMessageState.Button.Play, + progress = 1f, + time = "1:00", + eventSink = {} + ), + VoiceMessageState( + VoiceMessageState.Button.Pause, + progress = 0.2f, + time = "10:00", + eventSink = {} + ), + VoiceMessageState( + VoiceMessageState.Button.Disabled, + progress = 0.2f, + time = "30:00", + eventSink = {} + ), + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/WaveformProgressIndicator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/WaveformProgressIndicator.kt new file mode 100644 index 0000000000..94731ef0c8 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/WaveformProgressIndicator.kt @@ -0,0 +1,96 @@ +/* + * 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.features.messages.impl.voicemessages.timeline + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.unit.dp +import com.linc.audiowaveform.AudioWaveform +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.theme.ElementTheme +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList + +@Composable +fun WaveformProgressIndicator( + progress: Float, + amplitudes: ImmutableList, + modifier: Modifier = Modifier, + onSeek: (progress: Float) -> Unit = {}, +) { + var seekProgress: Float? by remember { mutableStateOf(null) } + val scaledAmplitudes = remember(amplitudes) { amplitudes.scaleAmplitudes() } + AudioWaveform( + modifier = modifier, + waveformBrush = SolidColor(ElementTheme.colors.iconQuaternary), + progressBrush = SolidColor(ElementTheme.colors.iconSecondary), + onProgressChangeFinished = { + // This is to send just one onSeek callback after the user has finished seeking. + // Otherwise the AudioWaveform library would send multiple callbacks while the user is seeking. + val p = seekProgress!! + seekProgress = null + onSeek(p) + }, + spikeWidth = 1.6.dp, + spikeRadius = 0.8.dp, + spikePadding = 3.dp, + progress = seekProgress ?: progress, + amplitudes = scaledAmplitudes, + onProgressChange = { seekProgress = it }, + ) +} + +@PreviewsDayNight +@Composable +internal fun WaveformProgressIndicatorPreview() = ElementPreview { + Column { + WaveformProgressIndicator( + progress = 0.5f, + amplitudes = persistentListOf(), + ) + WaveformProgressIndicator( + progress = 0.5f, + amplitudes = persistentListOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0), + ) + WaveformProgressIndicator( + progress = 0.5f, + amplitudes = List(1024) { it }.toPersistentList() + ) + } +} + +/** + * Scale amplitudes to fit in the waveform view. + * + * It seems amplitudes > 128 are clipped by the waveform library. + * Workaround for https://github.com/lincollincol/compose-audiowaveform/issues/22 + * + * TODO Voice messages: Remove this workaround when the waveform library is fixed. + */ +private fun ImmutableList.scaleAmplitudes(): List { + val maxAmplitude = if (isEmpty()) 1 else maxOf { it } + val scalingFactor = 128 / maxAmplitude.toFloat() + return map { (it * scalingFactor).toInt() } +} diff --git a/features/messages/impl/src/main/res/drawable/pause.xml b/features/messages/impl/src/main/res/drawable/pause.xml new file mode 100644 index 0000000000..875a9ce403 --- /dev/null +++ b/features/messages/impl/src/main/res/drawable/pause.xml @@ -0,0 +1,9 @@ + + + diff --git a/features/messages/impl/src/main/res/drawable/play.xml b/features/messages/impl/src/main/res/drawable/play.xml new file mode 100644 index 0000000000..4e9df7b71d --- /dev/null +++ b/features/messages/impl/src/main/res/drawable/play.xml @@ -0,0 +1,9 @@ + + + diff --git a/features/messages/impl/src/main/res/drawable/retry.xml b/features/messages/impl/src/main/res/drawable/retry.xml new file mode 100644 index 0000000000..c3fda8bca6 --- /dev/null +++ b/features/messages/impl/src/main/res/drawable/retry.xml @@ -0,0 +1,9 @@ + + + diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt index b02afd90fb..b5bbebfa33 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt @@ -40,7 +40,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent -import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerPresenter +import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter import io.element.android.features.messages.media.FakeLocalMediaFactory import io.element.android.features.messages.textcomposer.TestRichTextEditorStateFactory import io.element.android.features.messages.timeline.components.customreaction.FakeEmojibaseProvider @@ -66,6 +66,7 @@ import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID_2 +import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.aRoomMember import io.element.android.libraries.mediapickers.test.FakePickerProvider @@ -75,6 +76,7 @@ import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory import io.element.android.libraries.textcomposer.model.MessageComposerMode +import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.consumeItemsUntilPredicate @@ -607,20 +609,28 @@ class MessagesPresenterTest { analyticsService: FakeAnalyticsService = FakeAnalyticsService(), permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(), ): MessagesPresenter { + val mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom) + val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter) val messageComposerPresenter = MessageComposerPresenter( appCoroutineScope = this, room = matrixRoom, mediaPickerProvider = FakePickerProvider(), featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.NotificationSettings.key to true)), localMediaFactory = FakeLocalMediaFactory(mockMediaUrl), - mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom), + mediaSender = mediaSender, snackbarDispatcher = SnackbarDispatcher(), analyticsService = analyticsService, messageComposerContext = MessageComposerContextImpl(), richTextEditorStateFactory = TestRichTextEditorStateFactory(), - permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter), + permissionsPresenterFactory = permissionsPresenterFactory, + ) + val voiceMessageComposerPresenter = VoiceMessageComposerPresenter( + this, + FakeVoiceRecorder(), + analyticsService, + mediaSender, + permissionsPresenterFactory, ) - val voiceMessageComposerPresenter = VoiceMessageComposerPresenter() val timelinePresenter = TimelinePresenter( timelineItemsFactory = aTimelineItemsFactory(), room = matrixRoom, @@ -649,6 +659,7 @@ class MessagesPresenterTest { clipboardHelper = clipboardHelper, preferencesStore = preferencesStore, featureFlagsService = FakeFeatureFlagService(), + buildMeta = aBuildMeta(), dispatchers = coroutineDispatchers, ) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt index f94e79f0cb..460979f289 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt @@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent import io.element.android.libraries.featureflag.test.InMemoryPreferencesStore import io.element.android.libraries.matrix.test.A_MESSAGE import io.element.android.tests.testutils.WarmUpRule @@ -458,6 +459,33 @@ class ActionListPresenterTest { assertThat(successState.displayEmojiReactions).isTrue() } } + + @Test + fun `present - compute for voice message`() = runTest { + val presenter = createActionListPresenter(isDeveloperModeEnabled = false) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = true, + content = aTimelineItemVoiceContent(), + ) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true)) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + messageEvent, + persistentListOf( + TimelineItemAction.Reply, + TimelineItemAction.Forward, + TimelineItemAction.Redact, + ) + ) + ) + assertThat(successState.displayEmojiReactions).isTrue() + } + } } private fun createActionListPresenter(isDeveloperModeEnabled: Boolean): ActionListPresenter { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt index 0d4dc98340..18fa0e390e 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt @@ -49,7 +49,11 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory { dispatchers = testCoroutineDispatchers(), eventItemFactory = TimelineItemEventFactory( contentFactory = TimelineItemContentFactory( - messageFactory = TimelineItemContentMessageFactory(FakeFileSizeFormatter(), FileExtensionExtractorWithoutValidation()), + messageFactory = TimelineItemContentMessageFactory( + fileSizeFormatter = FakeFileSizeFormatter(), + fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), + featureFlagService = FakeFeatureFlagService(), + ), redactedMessageFactory = TimelineItemContentRedactedFactory(), stickerFactory = TimelineItemContentStickerFactory(), pollFactory = TimelineItemContentPollFactory(matrixClient, FakeFeatureFlagService()), diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/mediaplayer/FakeMediaPlayer.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/mediaplayer/FakeMediaPlayer.kt new file mode 100644 index 0000000000..a9f0349552 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/mediaplayer/FakeMediaPlayer.kt @@ -0,0 +1,71 @@ +/* + * 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.features.messages.mediaplayer + +import io.element.android.features.messages.impl.mediaplayer.MediaPlayer +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** + * Fake implementation of [MediaPlayer] for testing purposes. + */ +class FakeMediaPlayer : MediaPlayer { + private val _state = MutableStateFlow(MediaPlayer.State(false, null, 0L)) + + override val state: StateFlow = _state.asStateFlow() + + override fun acquireControlAndPlay(uri: String, mediaId: String, mimeType: String) { + _state.update { + it.copy( + isPlaying = true, + mediaId = mediaId, + currentPosition = it.currentPosition + 1000L, + ) + } + } + + override fun play() { + _state.update { + it.copy( + isPlaying = true, + currentPosition = it.currentPosition + 1000L, + ) + } + } + + override fun pause() { + _state.update { + it.copy( + isPlaying = false, + ) + } + } + + override fun seekTo(positionMs: Long) { + _state.update { + it.copy( + currentPosition = positionMs, + ) + } + } + + override fun close() { + // no-op + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/VoiceMessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/VoiceMessageComposerPresenterTest.kt index 008226bf05..58a7f54d48 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/VoiceMessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/VoiceMessageComposerPresenterTest.kt @@ -18,72 +18,431 @@ package io.element.android.features.messages.voicemessages +import android.Manifest +import androidx.lifecycle.Lifecycle import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow +import app.cash.turbine.TurbineTestContext import app.cash.turbine.test import com.google.common.truth.Truth.assertThat -import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerEvents -import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerPresenter +import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerEvents +import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter +import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState +import io.element.android.features.messages.impl.voicemessages.VoiceMessageException +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.mediaupload.api.MediaSender +import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor +import io.element.android.libraries.permissions.api.PermissionsPresenter +import io.element.android.libraries.permissions.api.aPermissionsState +import io.element.android.libraries.permissions.test.FakePermissionsPresenter +import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory import io.element.android.libraries.textcomposer.model.PressEvent import io.element.android.libraries.textcomposer.model.VoiceMessageState +import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder +import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.WarmUpRule import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test +import kotlin.time.Duration.Companion.seconds class VoiceMessageComposerPresenterTest { @get:Rule val warmUpRule = WarmUpRule() + private val voiceRecorder = FakeVoiceRecorder( + recordingDuration = RECORDING_DURATION + ) + private val analyticsService = FakeAnalyticsService() + private val matrixRoom = FakeMatrixRoom() + private val mediaPreProcessor = FakeMediaPreProcessor().apply { givenAudioResult() } + private val mediaSender = MediaSender(mediaPreProcessor, matrixRoom) + + companion object { + private val RECORDING_DURATION = 1.seconds + private val RECORDING_STATE = VoiceMessageState.Recording(RECORDING_DURATION, 0.2) + } + @Test fun `present - initial state`() = runTest { - val presenter = createPresenter() + val presenter = createVoiceMessageComposerPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) + voiceRecorder.assertCalls(started = 0) + + testPauseAndDestroy(initialState) } } @Test fun `present - recording state`() = runTest { - val presenter = createPresenter() + val presenter = createVoiceMessageComposerPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) - assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Recording) + val finalState = awaitItem() + assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE) + voiceRecorder.assertCalls(started = 1) + + testPauseAndDestroy(finalState) } } @Test fun `present - abort recording`() = runTest { - val presenter = createPresenter() + val presenter = createVoiceMessageComposerPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.Tapped)) - assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle) + val finalState = awaitItem() + assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) + voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) + + testPauseAndDestroy(finalState) } } @Test fun `present - finish recording`() = runTest { - val presenter = createPresenter() + val presenter = createVoiceMessageComposerPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd)) - assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle) + val finalState = awaitItem() + assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Preview) + voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0) + + testPauseAndDestroy(finalState) } } - private fun createPresenter() = VoiceMessageComposerPresenter() + + @Test + fun `present - delete recording`() = runTest { + val presenter = createVoiceMessageComposerPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) + awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd)) + awaitItem().eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage) + + val finalState = awaitItem() + assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) + voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) + + testPauseAndDestroy(finalState) + } + } + + @Test + fun `present - send recording`() = runTest { + val presenter = createVoiceMessageComposerPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) + awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd)) + awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage) + assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Sending) + + val finalState = awaitItem() + assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) + assertThat(matrixRoom.sendMediaCount).isEqualTo(1) + voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) + + testPauseAndDestroy(finalState) + } + } + + @Test + fun `present - send recording before previous completed, waits`() = runTest { + val presenter = createVoiceMessageComposerPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) + awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd)) + awaitItem().run { + eventSink(VoiceMessageComposerEvents.SendVoiceMessage) + eventSink(VoiceMessageComposerEvents.SendVoiceMessage) + } + assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Sending) + + val finalState = awaitItem() + assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) + assertThat(matrixRoom.sendMediaCount).isEqualTo(1) + voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) + + testPauseAndDestroy(finalState) + } + } + + @Test + fun `present - send failures aren't tracked`() = runTest { + // Let sending fail due to media preprocessing error + mediaPreProcessor.givenResult(Result.failure(Exception())) + val presenter = createVoiceMessageComposerPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) + awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd)) + awaitItem().apply { + assertThat(voiceMessageState).isEqualTo(VoiceMessageState.Preview) + eventSink(VoiceMessageComposerEvents.SendVoiceMessage) + } + + val finalState = awaitItem() + assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Sending) + assertThat(matrixRoom.sendMediaCount).isEqualTo(0) + assertThat(analyticsService.trackedErrors).hasSize(0) + voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0) + + testPauseAndDestroy(finalState) + } + } + + @Test + fun `present - send failures can be retried`() = runTest { + // Let sending fail due to media preprocessing error + val presenter = createVoiceMessageComposerPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + mediaPreProcessor.givenResult(Result.failure(Exception())) + awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) + awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd)) + val previewState = awaitItem() + + previewState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage) + assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Sending) + + ensureAllEventsConsumed() + assertThat(previewState.voiceMessageState).isEqualTo(VoiceMessageState.Preview) + assertThat(matrixRoom.sendMediaCount).isEqualTo(0) + + mediaPreProcessor.givenAudioResult() + previewState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage) + val finalState = awaitItem() + assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) + assertThat(matrixRoom.sendMediaCount).isEqualTo(1) + voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) + + testPauseAndDestroy(finalState) + } + } + + @Test + fun `present - send error - missing recording is tracked`() = runTest { + val presenter = createVoiceMessageComposerPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + // Send the message before recording anything + initialState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage) + + assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) + assertThat(matrixRoom.sendMediaCount).isEqualTo(0) + assertThat(analyticsService.trackedErrors).hasSize(1) + voiceRecorder.assertCalls(started = 0) + + testPauseAndDestroy(initialState) + } + } + + @Test + fun `present - record error - security exceptions are tracked`() = runTest { + val exception = SecurityException("") + voiceRecorder.givenThrowsSecurityException(exception) + val presenter = createVoiceMessageComposerPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) + + assertThat(matrixRoom.sendMediaCount).isEqualTo(0) + assertThat(analyticsService.trackedErrors).containsExactly( + VoiceMessageException.PermissionMissing(message = "Expected permission to record but none", cause = exception) + ) + voiceRecorder.assertCalls(started = 1) + + testPauseAndDestroy(initialState) + } + } + + @Test + fun `present - permission accepted first time`() = runTest { + val permissionsPresenter = createFakePermissionsPresenter( + recordPermissionGranted = false, + ) + val presenter = createVoiceMessageComposerPresenter( + permissionsPresenter = permissionsPresenter, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) + assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle) + + initialState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd)) + voiceRecorder.assertCalls(stopped = 1) + + permissionsPresenter.setPermissionGranted() + + awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) + val finalState = awaitItem() + assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE) + voiceRecorder.assertCalls(stopped = 1, started = 1) + + testPauseAndDestroy(finalState) + } + } + + @Test + fun `present - permission denied previously`() = runTest { + val permissionsPresenter = createFakePermissionsPresenter( + recordPermissionGranted = false, + ) + val presenter = createVoiceMessageComposerPresenter( + permissionsPresenter = permissionsPresenter, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) + + // See the dialog and accept it + awaitItem().also { + assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Idle) + assertThat(it.showPermissionRationaleDialog).isTrue() + it.eventSink(VoiceMessageComposerEvents.AcceptPermissionRationale) + } + + // Dialog is hidden, user accepts permissions + assertThat(awaitItem().showPermissionRationaleDialog).isFalse() + + permissionsPresenter.setPermissionGranted() + + awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) + val finalState = awaitItem() + assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE) + voiceRecorder.assertCalls(started = 1) + + testPauseAndDestroy(finalState) + } + } + + @Test + fun `present - permission rationale dismissed`() = runTest { + val permissionsPresenter = createFakePermissionsPresenter( + recordPermissionGranted = false, + ) + val presenter = createVoiceMessageComposerPresenter( + permissionsPresenter = permissionsPresenter, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) + + // See the dialog and accept it + awaitItem().also { + assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Idle) + assertThat(it.showPermissionRationaleDialog).isTrue() + it.eventSink(VoiceMessageComposerEvents.DismissPermissionsRationale) + } + + // Dialog is hidden, user tries to record again + awaitItem().also { + assertThat(it.showPermissionRationaleDialog).isFalse() + it.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) + } + + // Dialog is shown once again + val finalState = awaitItem().also { + assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Idle) + assertThat(it.showPermissionRationaleDialog).isTrue() + } + voiceRecorder.assertCalls(started = 0) + + testPauseAndDestroy(finalState) + } + } + + private suspend fun TurbineTestContext.testPauseAndDestroy( + mostRecentState: VoiceMessageComposerState, + ) { + mostRecentState.eventSink( + VoiceMessageComposerEvents.LifecycleEvent(event = Lifecycle.Event.ON_PAUSE) + ) + + val onPauseState = when (mostRecentState.voiceMessageState) { + VoiceMessageState.Idle, + VoiceMessageState.Preview, + VoiceMessageState.Sending -> { + mostRecentState + } + is VoiceMessageState.Recording -> { + awaitItem().also { + assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Preview) + } + } + } + + onPauseState.eventSink( + VoiceMessageComposerEvents.LifecycleEvent(event = Lifecycle.Event.ON_DESTROY) + ) + + when (onPauseState.voiceMessageState) { + VoiceMessageState.Idle, + VoiceMessageState.Sending -> + ensureAllEventsConsumed() + is VoiceMessageState.Recording, + VoiceMessageState.Preview -> + assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle) + } + } + + private fun TestScope.createVoiceMessageComposerPresenter( + permissionsPresenter: PermissionsPresenter = createFakePermissionsPresenter(), + ): VoiceMessageComposerPresenter { + return VoiceMessageComposerPresenter( + this, + voiceRecorder, + analyticsService, + mediaSender, + FakePermissionsPresenterFactory(permissionsPresenter), + ) + } + + private fun createFakePermissionsPresenter( + recordPermissionGranted: Boolean = true, + recordPermissionShowDialog: Boolean = false, + ): FakePermissionsPresenter { + val initialPermissionState = aPermissionsState( + showDialog = recordPermissionShowDialog, + permission = Manifest.permission.RECORD_AUDIO, + permissionGranted = recordPermissionGranted, + ) + return FakePermissionsPresenter( + initialState = initialPermissionState + ) + } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/FakeVoiceMessageCache.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/FakeVoiceMessageCache.kt new file mode 100644 index 0000000000..ee61a690e6 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/FakeVoiceMessageCache.kt @@ -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.features.messages.voicemessages.timeline + +import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageCache +import java.io.File + +/** + * A fake implementation of [VoiceMessageCache] for testing purposes. + */ +class FakeVoiceMessageCache : VoiceMessageCache { + + private var _cachePath: String = "" + private var _isInCache: Boolean = false + private var _moveToCache: Boolean = false + + override val cachePath: String + get() = _cachePath + + override fun isInCache(): Boolean = _isInCache + + override fun moveToCache(file: File): Boolean = _moveToCache + + fun givenCachePath(cachePath: String) { + _cachePath = cachePath + } + + fun givenIsInCache(isInCache: Boolean) { + _isInCache = isInCache + } + + fun givenMoveToCache(moveToCache: Boolean) { + _moveToCache = moveToCache + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/VoiceMessageCacheTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/VoiceMessageCacheTest.kt new file mode 100644 index 0000000000..3c7a6468a0 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/VoiceMessageCacheTest.kt @@ -0,0 +1,90 @@ +/* + * 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.features.messages.voicemessages.timeline + +import com.google.common.truth.Truth +import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageCacheImpl +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class VoiceMessageCacheTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `moveToVoiceCache() should move the file to the voice cache dir`() { + val rootPath = temporaryFolder.root.path + val file = File("$rootPath/myFile.txt").apply { createNewFile() } + val cacheDir = File("$rootPath/cacheDir").apply { if (!exists()) mkdirs() } + val mxcUri = "mxc://matrix.org/1234567890abcdefg" + val cache = VoiceMessageCacheImpl(cacheDir, mxcUri) + + Truth.assertThat(cache.moveToCache(file)) + .isTrue() + Truth.assertThat(File("$rootPath/cacheDir/temp/voice/matrix.org/1234567890abcdefg").exists()) + .isTrue() + } + + @Test + fun `voiceCachePath() should point to cacheDir-temp-voice-mxcUri2fileName`() { + val rootPath = temporaryFolder.root.path + val cacheDir = File("$rootPath/cacheDir") + val mxcUri = "mxc://matrix.org/1234567890abcdefg" + val cache = VoiceMessageCacheImpl(cacheDir, mxcUri) + + Truth.assertThat(cache.cachePath) + .isEqualTo("$rootPath/cacheDir/temp/voice/matrix.org/1234567890abcdefg") + } + + @Test + fun `isInVoiceCache() should return true if the file exists`() { + val rootPath = temporaryFolder.root.path + val cacheDir = File("$rootPath/cacheDir") + val mxcUri = "mxc://matrix.org/1234567890abcdefg" + val file = File("$rootPath/cacheDir/temp/voice/matrix.org/1234567890abcdefg").apply { + parentFile?.mkdirs() + createNewFile() + } + val cache = VoiceMessageCacheImpl(cacheDir, mxcUri) + + Truth.assertThat(cache.isInCache()) + .isTrue() + } + + @Test + fun `isInVoiceCache() should return false if the file does not exist`() { + val rootPath = temporaryFolder.root.path + val cacheDir = File("$rootPath/cacheDir") + val mxcUri = "mxc://matrix.org/1234567890abcdefg" + val cache = VoiceMessageCacheImpl(cacheDir, mxcUri) + + Truth.assertThat(cache.isInCache()) + .isFalse() + } + + @Test(expected = IllegalStateException::class) + fun `isInVoiceCache() throws IllegalStateException on bogus mxc uri`() { + val cacheDir = File("") + val mxcUri = "bogus" + val cache = VoiceMessageCacheImpl(cacheDir, mxcUri) + + cache.isInCache() + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/VoiceMessagePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/VoiceMessagePresenterTest.kt new file mode 100644 index 0000000000..c748679421 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/VoiceMessagePresenterTest.kt @@ -0,0 +1,294 @@ +/* + * 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.features.messages.voicemessages.timeline + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent +import io.element.android.features.messages.mediaplayer.FakeMediaPlayer +import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageEvents +import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessagePlayerImpl +import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessagePresenter +import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageState +import io.element.android.libraries.matrix.test.media.FakeMediaLoader +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class VoiceMessagePresenterTest { + + private val fakeMediaLoader = FakeMediaLoader() + private val fakeVoiceCache = FakeVoiceMessageCache() + + @Test + fun `initial state has proper default values`() = runTest { + val presenter = createVoiceMessagePresenter(fakeMediaLoader, fakeVoiceCache) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().let { + Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play) + Truth.assertThat(it.progress).isEqualTo(0f) + Truth.assertThat(it.time).isEqualTo("1:01") + } + } + } + + @Test + fun `pressing play with file in cache plays`() = runTest { + fakeVoiceCache.apply { + givenIsInCache(true) + } + val content = aTimelineItemVoiceContent(durationMs = 2_000) + val presenter = createVoiceMessagePresenter(fakeMediaLoader, fakeVoiceCache, content) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem().also { + Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play) + Truth.assertThat(it.progress).isEqualTo(0f) + Truth.assertThat(it.time).isEqualTo("0:02") + } + + initialState.eventSink(VoiceMessageEvents.PlayPause) + + awaitItem().also { + Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause) + Truth.assertThat(it.progress).isEqualTo(0.5f) + Truth.assertThat(it.time).isEqualTo("0:01") + } + } + } + + @Test + fun `pressing play with file not in cache downloads it but fails`() = runTest { + fakeMediaLoader.apply { + shouldFail = true + } + fakeVoiceCache.apply { + givenIsInCache(false) + givenMoveToCache(true) + } + val presenter = createVoiceMessagePresenter(fakeMediaLoader, fakeVoiceCache) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem().also { + Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play) + Truth.assertThat(it.progress).isEqualTo(0f) + Truth.assertThat(it.time).isEqualTo("1:01") + } + + initialState.eventSink(VoiceMessageEvents.PlayPause) + + awaitItem().also { + Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading) + Truth.assertThat(it.progress).isEqualTo(0f) + Truth.assertThat(it.time).isEqualTo("1:01") + } + + awaitItem().also { + Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Retry) + Truth.assertThat(it.progress).isEqualTo(0f) + Truth.assertThat(it.time).isEqualTo("1:01") + } + } + } + + @Test + fun `pressing play with file not in cache downloads it but then caching fails`() = runTest { + fakeMediaLoader.apply { + shouldFail = false + } + fakeVoiceCache.apply { + givenIsInCache(false) + givenMoveToCache(false) + } + val presenter = createVoiceMessagePresenter(fakeMediaLoader, fakeVoiceCache) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem().also { + Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play) + Truth.assertThat(it.progress).isEqualTo(0f) + Truth.assertThat(it.time).isEqualTo("1:01") + } + + initialState.eventSink(VoiceMessageEvents.PlayPause) + + awaitItem().also { + Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading) + Truth.assertThat(it.progress).isEqualTo(0f) + Truth.assertThat(it.time).isEqualTo("1:01") + } + + awaitItem().also { + Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Retry) + Truth.assertThat(it.progress).isEqualTo(0f) + Truth.assertThat(it.time).isEqualTo("1:01") + } + } + } + + @Test + fun `acquire control then play then play and pause while having control`() = runTest { + fakeVoiceCache.apply { + givenIsInCache(true) + } + val content = aTimelineItemVoiceContent(durationMs = 2_000) + val presenter = createVoiceMessagePresenter(fakeMediaLoader, fakeVoiceCache, content) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem().also { + Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play) + Truth.assertThat(it.progress).isEqualTo(0f) + Truth.assertThat(it.time).isEqualTo("0:02") + } + + initialState.eventSink(VoiceMessageEvents.PlayPause) + + awaitItem().also { + Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause) + Truth.assertThat(it.progress).isEqualTo(0.5f) + Truth.assertThat(it.time).isEqualTo("0:01") + } + + initialState.eventSink(VoiceMessageEvents.PlayPause) + + awaitItem().also { + Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play) + Truth.assertThat(it.progress).isEqualTo(0.5f) + Truth.assertThat(it.time).isEqualTo("0:01") + } + + initialState.eventSink(VoiceMessageEvents.PlayPause) + + awaitItem().also { + Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause) + Truth.assertThat(it.progress).isEqualTo(1.0f) + Truth.assertThat(it.time).isEqualTo("0:02") + } + } + } + + @Test + fun `pressing play with file not in cache downloads it successfully`() = runTest { + fakeMediaLoader.apply { + shouldFail = false + } + fakeVoiceCache.apply { + givenIsInCache(false) + givenMoveToCache(true) + } + val content = aTimelineItemVoiceContent(durationMs = 2_000) + val presenter = createVoiceMessagePresenter(fakeMediaLoader, fakeVoiceCache, content) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem().also { + Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play) + Truth.assertThat(it.progress).isEqualTo(0f) + Truth.assertThat(it.time).isEqualTo("0:02") + } + + initialState.eventSink(VoiceMessageEvents.PlayPause) + + awaitItem().also { + Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading) + Truth.assertThat(it.progress).isEqualTo(0f) + Truth.assertThat(it.time).isEqualTo("0:02") + } + + awaitItem().also { + Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause) + Truth.assertThat(it.progress).isEqualTo(0.5f) + Truth.assertThat(it.time).isEqualTo("0:01") + } + } + } + + @Test + fun `content with null eventId shows disabled button`() = runTest { + fakeMediaLoader.apply { + shouldFail = false + } + fakeVoiceCache.apply { + givenIsInCache(false) + givenMoveToCache(true) + } + val content = aTimelineItemVoiceContent(eventId = null) + val presenter = createVoiceMessagePresenter(fakeMediaLoader, fakeVoiceCache, content) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem().also { + Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Disabled) + Truth.assertThat(it.progress).isEqualTo(0f) + Truth.assertThat(it.time).isEqualTo("1:01") + } + } + } + + @Test + fun `seeking seeks`() = runTest { + fakeVoiceCache.apply { + givenIsInCache(true) + } + val content = aTimelineItemVoiceContent(durationMs = 10_000) + + val presenter = createVoiceMessagePresenter(fakeMediaLoader, fakeVoiceCache, content) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem().also { + Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play) + Truth.assertThat(it.progress).isEqualTo(0f) + Truth.assertThat(it.time).isEqualTo("0:10") + } + + initialState.eventSink(VoiceMessageEvents.PlayPause) + + awaitItem().also { + Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause) + Truth.assertThat(it.progress).isEqualTo(0.1f) + Truth.assertThat(it.time).isEqualTo("0:01") + } + + initialState.eventSink(VoiceMessageEvents.Seek(0.5f)) + + awaitItem().also { + Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause) + Truth.assertThat(it.progress).isEqualTo(0.5f) + Truth.assertThat(it.time).isEqualTo("0:05") + } + } + } +} + +fun createVoiceMessagePresenter( + fakeMediaLoader: FakeMediaLoader, + voiceCacheFake: FakeVoiceMessageCache, + content: TimelineItemVoiceContent = aTimelineItemVoiceContent(), +) = VoiceMessagePresenter( + mediaLoader = fakeMediaLoader, + voiceMessagePlayerFactory = { eventId, mediaPath -> VoiceMessagePlayerImpl(FakeMediaPlayer(), eventId, mediaPath) }, + voiceMessageCacheFactory = { voiceCacheFake }, + content = content, +) diff --git a/features/preferences/api/build.gradle.kts b/features/preferences/api/build.gradle.kts index c20fe9aabb..0278385ab3 100644 --- a/features/preferences/api/build.gradle.kts +++ b/features/preferences/api/build.gradle.kts @@ -15,6 +15,7 @@ */ plugins { id("io.element.android-library") + id("kotlin-parcelize") } android { diff --git a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt index 3d1a516593..a0d2b8e057 100644 --- a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt +++ b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt @@ -16,16 +16,30 @@ package io.element.android.features.preferences.api +import android.os.Parcelable import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import io.element.android.libraries.architecture.FeatureEntryPoint +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.parcelize.Parcelize interface PreferencesEntryPoint : FeatureEntryPoint { + sealed interface InitialTarget : Parcelable { + @Parcelize + data object Root : InitialTarget + @Parcelize + data object NotificationSettings : InitialTarget + } + + data class Params(val initialElement: InitialTarget) : NodeInputs fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder interface NodeBuilder { + + fun params(params: Params): NodeBuilder fun callback(callback: Callback): NodeBuilder fun build(): Node } @@ -33,5 +47,6 @@ interface PreferencesEntryPoint : FeatureEntryPoint { interface Callback : Plugin { fun onOpenBugReport() fun onVerifyClicked() + fun onOpenRoomNotificationSettings(roomId: RoomId) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPoint.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPoint.kt index aa286394dc..e551d9d8dc 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPoint.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPoint.kt @@ -31,6 +31,11 @@ class DefaultPreferencesEntryPoint @Inject constructor() : PreferencesEntryPoint return object : PreferencesEntryPoint.NodeBuilder { val plugins = ArrayList() + override fun params(params: PreferencesEntryPoint.Params): PreferencesEntryPoint.NodeBuilder { + plugins += params + return this + } + override fun callback(callback: PreferencesEntryPoint.Callback): PreferencesEntryPoint.NodeBuilder { plugins += callback return this @@ -42,3 +47,8 @@ class DefaultPreferencesEntryPoint @Inject constructor() : PreferencesEntryPoint } } } + +internal fun PreferencesEntryPoint.InitialTarget.toNavTarget() = when (this) { + is PreferencesEntryPoint.InitialTarget.Root -> PreferencesFlowNode.NavTarget.Root + is PreferencesEntryPoint.InitialTarget.NotificationSettings -> PreferencesFlowNode.NavTarget.NotificationSettings +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt index 5144cfeb3f..2b46fa0746 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt @@ -44,6 +44,7 @@ import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler import io.element.android.libraries.architecture.createNode import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.user.MatrixUser import kotlinx.parcelize.Parcelize @@ -54,7 +55,7 @@ class PreferencesFlowNode @AssistedInject constructor( private val lockScreenEntryPoint: LockScreenEntryPoint, ) : BackstackNode( backstack = BackStack( - initialElement = NavTarget.Root, + initialElement = plugins.filterIsInstance().first().initialElement.toNavTarget(), savedStateMap = buildContext.savedStateMap, ), buildContext = buildContext, @@ -161,8 +162,13 @@ class PreferencesFlowNode @AssistedInject constructor( createNode(buildContext, listOf(notificationSettingsCallback)) } is NavTarget.EditDefaultNotificationSetting -> { + val callback = object : EditDefaultNotificationSettingNode.Callback { + override fun openRoomNotificationSettings(roomId: RoomId) { + plugins().forEach { it.onOpenRoomNotificationSettings(roomId) } + } + } val input = EditDefaultNotificationSettingNode.Inputs(navTarget.isOneToOne) - createNode(buildContext, plugins = listOf(input)) + createNode(buildContext, plugins = listOf(input, callback)) } NavTarget.AdvancedSettings -> { createNode(buildContext) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvents.kt index 374b8078ca..9e87675b3a 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvents.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvents.kt @@ -24,4 +24,5 @@ sealed interface NotificationSettingsEvents { data class SetCallNotificationsEnabled(val enabled: Boolean) : NotificationSettingsEvents data object FixConfigurationMismatch : NotificationSettingsEvents data object ClearConfigurationMismatchError : NotificationSettingsEvents + data object ClearNotificationChangeError : NotificationSettingsEvents } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt index 697d5887f0..689cca8f66 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt @@ -23,7 +23,9 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService import io.element.android.libraries.matrix.api.room.RoomNotificationMode @@ -50,6 +52,7 @@ class NotificationSettingsPresenter @Inject constructor( val systemNotificationsEnabled: MutableState = remember { mutableStateOf(systemNotificationsEnabledProvider.notificationsEnabled()) } + val changeNotificationSettingAction: MutableState> = remember { mutableStateOf(Async.Uninitialized) } val localCoroutineScope = rememberCoroutineScope() val appNotificationsEnabled = userPushStore @@ -67,8 +70,12 @@ class NotificationSettingsPresenter @Inject constructor( fun handleEvents(event: NotificationSettingsEvents) { when (event) { - is NotificationSettingsEvents.SetAtRoomNotificationsEnabled -> localCoroutineScope.setAtRoomNotificationsEnabled(event.enabled) - is NotificationSettingsEvents.SetCallNotificationsEnabled -> localCoroutineScope.setCallNotificationsEnabled(event.enabled) + is NotificationSettingsEvents.SetAtRoomNotificationsEnabled -> { + localCoroutineScope.setAtRoomNotificationsEnabled(event.enabled, changeNotificationSettingAction) + } + is NotificationSettingsEvents.SetCallNotificationsEnabled -> { + localCoroutineScope.setCallNotificationsEnabled(event.enabled, changeNotificationSettingAction) + } is NotificationSettingsEvents.SetNotificationsEnabled -> localCoroutineScope.setNotificationsEnabled(userPushStore, event.enabled) NotificationSettingsEvents.ClearConfigurationMismatchError -> { matrixSettings.value = NotificationSettingsState.MatrixSettings.Invalid(fixFailed = false) @@ -77,6 +84,7 @@ class NotificationSettingsPresenter @Inject constructor( NotificationSettingsEvents.RefreshSystemNotificationsEnabled -> { systemNotificationsEnabled.value = systemNotificationsEnabledProvider.notificationsEnabled() } + NotificationSettingsEvents.ClearNotificationChangeError -> changeNotificationSettingAction.value = Async.Uninitialized } } @@ -86,6 +94,7 @@ class NotificationSettingsPresenter @Inject constructor( systemNotificationsEnabled = systemNotificationsEnabled.value, appNotificationsEnabled = appNotificationsEnabled.value ), + changeNotificationSettingAction = changeNotificationSettingAction.value, eventSink = ::handleEvents ) } @@ -154,12 +163,16 @@ class NotificationSettingsPresenter @Inject constructor( ) } - private fun CoroutineScope.setAtRoomNotificationsEnabled(enabled: Boolean) = launch { - notificationSettingsService.setRoomMentionEnabled(enabled) + private fun CoroutineScope.setAtRoomNotificationsEnabled(enabled: Boolean, action: MutableState>) = launch { + suspend { + notificationSettingsService.setRoomMentionEnabled(enabled).getOrThrow() + }.runCatchingUpdatingState(action) } - private fun CoroutineScope.setCallNotificationsEnabled(enabled: Boolean) = launch { - notificationSettingsService.setCallEnabled(enabled) + private fun CoroutineScope.setCallNotificationsEnabled(enabled: Boolean, action: MutableState>) = launch { + suspend { + notificationSettingsService.setCallEnabled(enabled).getOrThrow() + }.runCatchingUpdatingState(action) } private fun CoroutineScope.setNotificationsEnabled(userPushStore: UserPushStore, enabled: Boolean) = launch { diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt index cf3cf6e3d0..2b0faa110c 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt @@ -17,12 +17,14 @@ package io.element.android.features.preferences.impl.notifications import androidx.compose.runtime.Immutable +import io.element.android.libraries.architecture.Async import io.element.android.libraries.matrix.api.room.RoomNotificationMode @Immutable data class NotificationSettingsState( val matrixSettings: MatrixSettings, val appSettings: AppSettings, + val changeNotificationSettingAction: Async, val eventSink: (NotificationSettingsEvents) -> Unit, ) { sealed interface MatrixSettings { diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStateProvider.kt index 1e653c47e0..9336857585 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStateProvider.kt @@ -17,16 +17,21 @@ package io.element.android.features.preferences.impl.notifications import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.Async import io.element.android.libraries.matrix.api.room.RoomNotificationMode open class NotificationSettingsStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aNotificationSettingsState(), + aNotificationSettingsState(changeNotificationSettingAction = Async.Loading(Unit)), + aNotificationSettingsState(changeNotificationSettingAction = Async.Failure(Throwable("error"))), ) } -fun aNotificationSettingsState() = NotificationSettingsState( +fun aNotificationSettingsState( + changeNotificationSettingAction: Async = Async.Uninitialized, +) = NotificationSettingsState( matrixSettings = NotificationSettingsState.MatrixSettings.Valid( atRoomNotificationsEnabled = true, callNotificationsEnabled = true, @@ -37,5 +42,6 @@ fun aNotificationSettingsState() = NotificationSettingsState( systemNotificationsEnabled = false, appNotificationsEnabled = true, ), + changeNotificationSettingAction = changeNotificationSettingAction, eventSink = {} ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt index 0844480697..223107226e 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt @@ -34,6 +34,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import io.element.android.libraries.androidutils.system.startNotificationSettingsIntent +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch @@ -87,9 +89,23 @@ fun NotificationSettingsView( onGroupChatsClicked = { onOpenEditDefault(false) }, onDirectChatsClicked = { onOpenEditDefault(true) }, onMentionNotificationsChanged = { state.eventSink(NotificationSettingsEvents.SetAtRoomNotificationsEnabled(it)) }, + // TODO We are removing the call notification toggle until support for call notifications has been added // onCallsNotificationsChanged = { state.eventSink(NotificationSettingsEvents.SetCallNotificationsEnabled(it)) }, ) } + when (state.changeNotificationSettingAction) { + is Async.Loading -> { + ProgressDialog() + } + is Async.Failure -> { + ErrorDialog( + title = stringResource(CommonStrings.dialog_title_error), + content = stringResource(CommonStrings.screen_notification_settings_edit_failed_updating_default_mode), + onDismiss = { state.eventSink(NotificationSettingsEvents.ClearNotificationChangeError) }, + ) + } + else -> Unit + } } } @@ -101,6 +117,7 @@ private fun NotificationSettingsContentView( onGroupChatsClicked: () -> Unit, onDirectChatsClicked: () -> Unit, onMentionNotificationsChanged: (Boolean) -> Unit, + // TODO We are removing the call notification toggle until support for call notifications has been added // onCallsNotificationsChanged: (Boolean) -> Unit, modifier: Modifier = Modifier, ) { @@ -151,7 +168,7 @@ private fun NotificationSettingsContentView( onCheckedChange = onMentionNotificationsChanged ) } - // We are removing the call notification toggle until call support has been added + // TODO We are removing the call notification toggle until support for call notifications has been added // PreferenceCategory(title = stringResource(id = CommonStrings.screen_notification_settings_additional_settings_section_title)) { // PreferenceSwitch( // modifier = Modifier, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingNode.kt index 6c4fd646f4..535203e35e 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingNode.kt @@ -21,12 +21,14 @@ import androidx.compose.ui.Modifier import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.RoomId @ContributesNode(SessionScope::class) class EditDefaultNotificationSettingNode @AssistedInject constructor( @@ -35,20 +37,30 @@ class EditDefaultNotificationSettingNode @AssistedInject constructor( presenterFactory: EditDefaultNotificationSettingPresenter.Factory ) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun openRoomNotificationSettings(roomId: RoomId) + } + data class Inputs( val isOneToOne: Boolean ) : NodeInputs private val inputs = inputs() + private val callbacks = plugins() private val presenter = presenterFactory.create(inputs.isOneToOne) + private fun openRoomNotificationSettings(roomId: RoomId) { + callbacks.forEach { it.openRoomNotificationSettings(roomId) } + } + @Composable override fun View(modifier: Modifier) { val state = presenter.present() EditDefaultNotificationSettingView( state = state, + openRoomNotificationSettings = { openRoomNotificationSettings(it) }, onBackPressed = ::navigateUp, - modifier = modifier + modifier = modifier, ) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingPresenter.kt index 764b37c52d..79201e27d3 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingPresenter.kt @@ -25,20 +25,28 @@ import androidx.compose.runtime.rememberCoroutineScope import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState +import io.element.android.libraries.matrix.api.MatrixClient 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.roomlist.RoomListService +import io.element.android.libraries.matrix.api.roomlist.RoomSummary import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import java.text.Collator import kotlin.time.Duration.Companion.seconds class EditDefaultNotificationSettingPresenter @AssistedInject constructor( private val notificationSettingsService: NotificationSettingsService, @Assisted private val isOneToOne: Boolean, + private val roomListService: RoomListService, + private val matrixClient: MatrixClient, ) : Presenter { @AssistedFactory interface Factory { @@ -50,21 +58,34 @@ class EditDefaultNotificationSettingPresenter @AssistedInject constructor( val mode: MutableState = remember { mutableStateOf(null) } + + val changeNotificationSettingAction: MutableState> = remember { mutableStateOf(Async.Uninitialized) } + + val roomsWithUserDefinedMode: MutableState> = remember { + mutableStateOf(listOf()) + } + val localCoroutineScope = rememberCoroutineScope() LaunchedEffect(Unit) { fetchSettings(mode) observeNotificationSettings(mode) + observeRoomSummaries(roomsWithUserDefinedMode) } fun handleEvents(event: EditDefaultNotificationSettingStateEvents) { when (event) { - is EditDefaultNotificationSettingStateEvents.SetNotificationMode -> localCoroutineScope.setDefaultNotificationMode(event.mode) + is EditDefaultNotificationSettingStateEvents.SetNotificationMode -> { + localCoroutineScope.setDefaultNotificationMode(event.mode, changeNotificationSettingAction) + } + EditDefaultNotificationSettingStateEvents.ClearError -> changeNotificationSettingAction.value = Async.Uninitialized } } return EditDefaultNotificationSettingState( isOneToOne = isOneToOne, mode = mode.value, + roomsWithUserDefinedMode = roomsWithUserDefinedMode.value, + changeNotificationSettingAction = changeNotificationSettingAction.value, eventSink = ::handleEvents ) } @@ -83,10 +104,39 @@ class EditDefaultNotificationSettingPresenter @AssistedInject constructor( .launchIn(this) } - private fun CoroutineScope.setDefaultNotificationMode(mode: RoomNotificationMode) = launch { - // On modern clients, we don't have different settings for encrypted and non-encrypted rooms (Legacy clients did). - notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = true, mode = mode, isOneToOne = isOneToOne) - notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = false, mode = mode, isOneToOne = isOneToOne) + private fun CoroutineScope.observeRoomSummaries(roomsWithUserDefinedMode: MutableState>) { + roomListService.allRooms() + .summaries + .onEach { + updateRoomsWithUserDefinedMode(it, roomsWithUserDefinedMode) + } + .launchIn(this) + } + + private fun CoroutineScope.updateRoomsWithUserDefinedMode( + summaries: List, + roomsWithUserDefinedMode: MutableState> + ) = launch { + val roomWithUserDefinedRules: Set = notificationSettingsService.getRoomsWithUserDefinedRules().getOrThrow().toSet() + + val sortedSummaries = summaries + .filterIsInstance() + .filter { + val room = matrixClient.getRoom(it.details.roomId) ?: return@filter false + roomWithUserDefinedRules.contains(it.identifier()) && isOneToOne == room.isOneToOne + } + // locale sensitive sorting + .sortedWith(compareBy(Collator.getInstance()){ it.details.name }) + + roomsWithUserDefinedMode.value = sortedSummaries + } + + private fun CoroutineScope.setDefaultNotificationMode(mode: RoomNotificationMode, action: MutableState>) = launch { + suspend { + // On modern clients, we don't have different settings for encrypted and non-encrypted rooms (Legacy clients did). + notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = true, mode = mode, isOneToOne = isOneToOne).getOrThrow() + notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = false, mode = mode, isOneToOne = isOneToOne).getOrThrow() + }.runCatchingUpdatingState(action) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingState.kt index 62c708d988..e8590ec27f 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingState.kt @@ -16,10 +16,14 @@ package io.element.android.features.preferences.impl.notifications.edit +import io.element.android.libraries.architecture.Async import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.api.roomlist.RoomSummary data class EditDefaultNotificationSettingState( val isOneToOne: Boolean, val mode: RoomNotificationMode?, + val roomsWithUserDefinedMode: List, + val changeNotificationSettingAction: Async, val eventSink: (EditDefaultNotificationSettingStateEvents) -> Unit, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateEvents.kt index 75c9b6c1a4..f5774f1d78 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateEvents.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateEvents.kt @@ -20,4 +20,5 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationMode sealed interface EditDefaultNotificationSettingStateEvents { data class SetNotificationMode(val mode: RoomNotificationMode): EditDefaultNotificationSettingStateEvents + data object ClearError: EditDefaultNotificationSettingStateEvents } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateProvider.kt new file mode 100644 index 0000000000..71fbfb1e1b --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateProvider.kt @@ -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.features.preferences.impl.notifications.edit + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.api.roomlist.RoomSummary +import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails + +open class EditDefaultNotificationSettingStateProvider: PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + anEditDefaultNotificationSettingsState(), + anEditDefaultNotificationSettingsState(isOneToOne = true), + anEditDefaultNotificationSettingsState(changeNotificationSettingAction = Async.Loading(Unit)), + anEditDefaultNotificationSettingsState(changeNotificationSettingAction = Async.Failure(Throwable("error"))), + ) +} + +private fun anEditDefaultNotificationSettingsState( + isOneToOne: Boolean = false, + changeNotificationSettingAction: Async = Async.Uninitialized +) = EditDefaultNotificationSettingState( + isOneToOne = isOneToOne, + mode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY, + roomsWithUserDefinedMode = listOf(aRoomSummary()), + changeNotificationSettingAction = changeNotificationSettingAction, + eventSink = {} +) + +private fun aRoomSummary() = RoomSummary.Filled( + RoomSummaryDetails( + roomId = RoomId("!roomId:domain"), + name = "Room", + avatarURLString = null, + isDirect = false, + lastMessage = null, + lastMessageTimestamp = null, + unreadNotificationCount = 0, + notificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY, + ) +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt index 3c2c27ac5c..94f5a6b053 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt @@ -21,8 +21,21 @@ import androidx.compose.foundation.selection.selectableGroup import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.components.ProgressDialog +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog +import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.designsystem.components.preferences.PreferencePage +import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.ui.strings.CommonStrings @@ -33,11 +46,12 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun EditDefaultNotificationSettingView( state: EditDefaultNotificationSettingState, + openRoomNotificationSettings:(roomId: RoomId) -> Unit, onBackPressed: () -> Unit, modifier: Modifier = Modifier, ) { - val title = if(state.isOneToOne) { + val title = if (state.isOneToOne) { CommonStrings.screen_notification_settings_direct_chats } else { CommonStrings.screen_notification_settings_group_chats @@ -51,7 +65,7 @@ fun EditDefaultNotificationSettingView( // Only ALL_MESSAGES and MENTIONS_AND_KEYWORDS_ONLY are valid global defaults. val validModes = listOf(RoomNotificationMode.ALL_MESSAGES, RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY) - val categoryTitle = if(state.isOneToOne) { + val categoryTitle = if (state.isOneToOne) { CommonStrings.screen_notification_settings_edit_screen_direct_section_header } else { CommonStrings.screen_notification_settings_edit_screen_group_section_header @@ -70,6 +84,63 @@ fun EditDefaultNotificationSettingView( } } } + if (state.roomsWithUserDefinedMode.isNotEmpty()) { + PreferenceCategory(title = stringResource(id = CommonStrings.screen_notification_settings_edit_custom_settings_section_title)) { + state.roomsWithUserDefinedMode.forEach { summary -> + val subtitle = when (summary.details.notificationMode) { + RoomNotificationMode.ALL_MESSAGES -> stringResource(id = CommonStrings.screen_notification_settings_edit_mode_all_messages) + RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> { + stringResource(id = CommonStrings.screen_notification_settings_edit_mode_mentions_and_keywords) + } + RoomNotificationMode.MUTE -> stringResource(id = CommonStrings.common_mute) + null -> "" + } + val avatarData = AvatarData( + id = summary.identifier(), + name = summary.details.name, + url = summary.details.avatarURLString, + size = AvatarSize.CustomRoomNotificationSetting, + ) + ListItem( + headlineContent = { + Text(text = summary.details.name) + }, + supportingContent = { + Text(text = subtitle) + }, + leadingContent = ListItemContent.Custom { + Avatar(avatarData = avatarData) + }, + onClick = { + openRoomNotificationSettings(summary.details.roomId) + } + ) + } + } + } + when (state.changeNotificationSettingAction) { + is Async.Loading -> { + ProgressDialog() + } + is Async.Failure -> { + ErrorDialog( + title = stringResource(CommonStrings.dialog_title_error), + content = stringResource(CommonStrings.screen_notification_settings_edit_failed_updating_default_mode), + onDismiss = { state.eventSink(EditDefaultNotificationSettingStateEvents.ClearError) }, + ) + } + else -> Unit + } } } - +@PreviewsDayNight +@Composable +internal fun EditDefaultNotificationSettingViewPreview( + @PreviewParameter(EditDefaultNotificationSettingStateProvider::class) state: EditDefaultNotificationSettingState +) = ElementPreview { + EditDefaultNotificationSettingView( + state = state, + openRoomNotificationSettings = {}, + onBackPressed = {}, + ) +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/EditDefaultNotificationSettingsPresenterTests.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/EditDefaultNotificationSettingsPresenterTests.kt index e8c9ff4fe5..f4cc7c80e9 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/EditDefaultNotificationSettingsPresenterTests.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/EditDefaultNotificationSettingsPresenterTests.kt @@ -23,7 +23,14 @@ import com.google.common.truth.Truth import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingPresenter import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingStateEvents import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.api.roomlist.RoomSummary +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_THROWABLE +import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.matrix.test.room.aRoomSummaryDetail +import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.tests.testutils.consumeItemsUntilPredicate import kotlinx.coroutines.test.runTest import org.junit.Test @@ -32,7 +39,7 @@ class EditDefaultNotificationSettingsPresenterTests { @Test fun `present - ensures initial state is correct`() = runTest { val notificationSettingsService = FakeNotificationSettingsService() - val presenter = EditDefaultNotificationSettingPresenter(notificationSettingsService = notificationSettingsService, isOneToOne = false) + val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -47,10 +54,32 @@ class EditDefaultNotificationSettingsPresenterTests { } } + @Test + fun `present - ensure list of rooms with user defined mode`() = runTest { + val room = FakeMatrixRoom() + val notificationSettingsService = FakeNotificationSettingsService( + initialRoomMode = RoomNotificationMode.ALL_MESSAGES, + initialRoomModeIsDefault = false + ) + val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService).apply { + givenGetRoomResult(A_ROOM_ID, room) + } + val roomListService = FakeRoomListService() + val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService, roomListService, matrixClient) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + roomListService.postAllRooms(listOf(RoomSummary.Filled(aRoomSummaryDetail(notificationMode = RoomNotificationMode.ALL_MESSAGES)))) + val loadedState = consumeItemsUntilPredicate { state -> + state.roomsWithUserDefinedMode.any { it.details.notificationMode == RoomNotificationMode.ALL_MESSAGES } + }.last() + Truth.assertThat(loadedState.roomsWithUserDefinedMode.any { it.details.notificationMode == RoomNotificationMode.ALL_MESSAGES }).isTrue() + } + } + @Test fun `present - edit default notification setting`() = runTest { - val notificationSettingsService = FakeNotificationSettingsService() - val presenter = EditDefaultNotificationSettingPresenter(notificationSettingsService = notificationSettingsService, isOneToOne = false) + val presenter = createEditDefaultNotificationSettingPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -61,4 +90,39 @@ class EditDefaultNotificationSettingsPresenterTests { Truth.assertThat(loadedState.mode).isEqualTo(RoomNotificationMode.ALL_MESSAGES) } } + + @Test + fun `present - edit default notification setting failed`() = runTest { + val notificationSettingsService = FakeNotificationSettingsService() + val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService) + notificationSettingsService.givenSetDefaultNotificationModeError(A_THROWABLE) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().eventSink(EditDefaultNotificationSettingStateEvents.SetNotificationMode(RoomNotificationMode.ALL_MESSAGES)) + val errorState = consumeItemsUntilPredicate { + it.changeNotificationSettingAction.isFailure() + }.last() + Truth.assertThat(errorState.changeNotificationSettingAction.isFailure()).isTrue() + errorState.eventSink(EditDefaultNotificationSettingStateEvents.ClearError) + val clearErrorState = consumeItemsUntilPredicate { + it.changeNotificationSettingAction.isUninitialized() + }.last() + Truth.assertThat(clearErrorState.changeNotificationSettingAction.isUninitialized()).isTrue() + } + } + + private fun createEditDefaultNotificationSettingPresenter( + notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(), + roomListService: FakeRoomListService = FakeRoomListService(), + matrixClient: FakeMatrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService) + ): EditDefaultNotificationSettingPresenter { + return EditDefaultNotificationSettingPresenter( + notificationSettingsService = notificationSettingsService, + isOneToOne = false, + roomListService = roomListService, + matrixClient = matrixClient + ) + } + } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTests.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTests.kt index cc2c0a7035..c3d6f51256 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTests.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTests.kt @@ -22,6 +22,7 @@ import app.cash.turbine.test import com.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory import com.google.common.truth.Truth import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.test.A_THROWABLE import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService import io.element.android.tests.testutils.consumeItemsUntilPredicate @@ -187,6 +188,35 @@ class NotificationSettingsPresenterTests { } } + @Test + fun `present - clear notification settings change error`() = runTest { + val notificationSettingsService = FakeNotificationSettingsService() + val presenter = createNotificationSettingsPresenter(notificationSettingsService) + notificationSettingsService.givenSetAtRoomError(A_THROWABLE) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val loadedState = consumeItemsUntilPredicate { + (it.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid)?.atRoomNotificationsEnabled == false + }.last() + val validMatrixState = loadedState.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid + Truth.assertThat(validMatrixState?.atRoomNotificationsEnabled).isFalse() + + loadedState.eventSink(NotificationSettingsEvents.SetAtRoomNotificationsEnabled(true)) + val errorState = consumeItemsUntilPredicate { + it.changeNotificationSettingAction.isFailure() + }.last() + Truth.assertThat(errorState.changeNotificationSettingAction.isFailure()).isTrue() + errorState.eventSink(NotificationSettingsEvents.ClearNotificationChangeError) + + val clearErrorState = consumeItemsUntilPredicate { + it.changeNotificationSettingAction.isUninitialized() + }.last() + Truth.assertThat(clearErrorState.changeNotificationSettingAction.isUninitialized()).isTrue() + cancelAndIgnoreRemainingEvents() + } + } + private fun createNotificationSettingsPresenter( notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService() ) : NotificationSettingsPresenter { diff --git a/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt b/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt index 4fa5c18b2e..0aaac324da 100644 --- a/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt +++ b/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt @@ -33,9 +33,22 @@ interface RoomDetailsEntryPoint : FeatureEntryPoint { @Parcelize data class RoomMemberDetails(val roomMemberId: UserId) : InitialTarget + + @Parcelize + data object RoomNotificationSettings : InitialTarget } - data class Inputs(val initialElement: InitialTarget) : NodeInputs + data class Params(val initialElement: InitialTarget) : NodeInputs - fun createNode(parentNode: Node, buildContext: BuildContext, inputs: Inputs, plugins: List): Node + interface Callback : Plugin { + fun onOpenGlobalNotificationSettings() + } + + interface NodeBuilder { + fun params(params: Params): NodeBuilder + fun callback(callback: Callback): NodeBuilder + fun build(): Node + } + + fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder } diff --git a/features/roomdetails/impl/build.gradle.kts b/features/roomdetails/impl/build.gradle.kts index 3bf38d4fa7..a2fdfa18c1 100644 --- a/features/roomdetails/impl/build.gradle.kts +++ b/features/roomdetails/impl/build.gradle.kts @@ -44,6 +44,7 @@ dependencies { implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.featureflag.api) implementation(projects.libraries.permissions.api) + implementation(projects.libraries.preferences.api) api(projects.features.roomdetails.api) api(projects.libraries.usersearch.api) api(projects.services.apperror.api) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPoint.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPoint.kt index be6b915212..108ef088f0 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPoint.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPoint.kt @@ -29,17 +29,30 @@ import javax.inject.Inject @ContributesBinding(AppScope::class) class DefaultRoomDetailsEntryPoint @Inject constructor() : RoomDetailsEntryPoint { - override fun createNode( - parentNode: Node, - buildContext: BuildContext, - inputs: RoomDetailsEntryPoint.Inputs, - plugins: List - ): Node { - return parentNode.createNode(buildContext, plugins + inputs) + + override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): RoomDetailsEntryPoint.NodeBuilder { + return object : RoomDetailsEntryPoint.NodeBuilder { + val plugins = ArrayList() + + override fun params(params: RoomDetailsEntryPoint.Params): RoomDetailsEntryPoint.NodeBuilder { + plugins += params + return this + } + + override fun callback(callback: RoomDetailsEntryPoint.Callback): RoomDetailsEntryPoint.NodeBuilder { + plugins += callback + return this + } + + override fun build(): Node { + return parentNode.createNode(buildContext, plugins) + } + } } } internal fun InitialTarget.toNavTarget() = when (this) { is InitialTarget.RoomDetails -> NavTarget.RoomDetails is InitialTarget.RoomMemberDetails -> NavTarget.RoomMemberDetails(roomMemberId) + is InitialTarget.RoomNotificationSettings -> NavTarget.RoomNotificationSettings(showUserDefinedSettingStyle = true) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt index 71eff4e9e6..dba31ab223 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt @@ -23,6 +23,7 @@ import com.bumble.appyx.core.composable.Children import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.operation.push import dagger.assisted.Assisted @@ -47,7 +48,7 @@ class RoomDetailsFlowNode @AssistedInject constructor( @Assisted plugins: List, ) : BackstackNode( backstack = BackStack( - initialElement = plugins.filterIsInstance().first().initialElement.toNavTarget(), + initialElement = plugins.filterIsInstance().first().initialElement.toNavTarget(), savedStateMap = buildContext.savedStateMap, ), buildContext = buildContext, @@ -68,7 +69,13 @@ class RoomDetailsFlowNode @AssistedInject constructor( data object InviteMembers : NavTarget @Parcelize - data object RoomNotificationSettings : NavTarget + data class RoomNotificationSettings( + /** + * When presented from outsite the context of the room, the rooms settings UI is different. + * Figma designs: https://www.figma.com/file/0MMNu7cTOzLOlWb7ctTkv3/Element-X?type=design&node-id=5199-198932&mode=design&t=fTTvpuxYFjewYQOe-0 + */ + val showUserDefinedSettingStyle: Boolean + ) : NavTarget @Parcelize data class RoomMemberDetails(val roomMemberId: UserId) : NavTarget @@ -91,7 +98,7 @@ class RoomDetailsFlowNode @AssistedInject constructor( } override fun openRoomNotificationSettings() { - backstack.push(NavTarget.RoomNotificationSettings) + backstack.push(NavTarget.RoomNotificationSettings(showUserDefinedSettingStyle = false)) } } createNode(buildContext, listOf(roomDetailsCallback)) @@ -118,8 +125,14 @@ class RoomDetailsFlowNode @AssistedInject constructor( createNode(buildContext) } - NavTarget.RoomNotificationSettings -> { - createNode(buildContext) + is NavTarget.RoomNotificationSettings -> { + val input = RoomNotificationSettingsNode.RoomNotificationSettingInput(navTarget.showUserDefinedSettingStyle) + val callback = object : RoomNotificationSettingsNode.Callback { + override fun openGlobalNotificationSettings() { + plugins().forEach { it.onOpenGlobalNotificationSettings() } + } + } + createNode(buildContext, listOf(input, callback)) } is NavTarget.RoomMemberDetails -> { diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt index 72ef265749..ad76f78515 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt @@ -36,7 +36,11 @@ open class RoomDetailsStateProvider : PreviewParameterProvider aDmRoomDetailsState().copy(roomName = "Daniel"), aDmRoomDetailsState(isDmMemberIgnored = true).copy(roomName = "Daniel"), aRoomDetailsState().copy(canInvite = true), - aRoomDetailsState().copy(canEdit = true), + aRoomDetailsState().copy( + canEdit = true, + // Also test the roomNotificationSettings ALL_MESSAGES in the same screenshot. Icon 'Mute' should be displayed + roomNotificationSettings = RoomNotificationSettings(mode = RoomNotificationMode.ALL_MESSAGES, isDefault = true) + ), // Add other state here ) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt index c6b90e5cc6..f3c6b13253 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt @@ -19,7 +19,6 @@ package io.element.android.features.roomdetails.impl import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets @@ -76,7 +75,6 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalLayoutApi::class) @Composable fun RoomDetailsView( state: RoomDetailsState, diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsEvents.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsEvents.kt index bbe756b154..8b3c25d267 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsEvents.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsEvents.kt @@ -21,4 +21,7 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationMode sealed interface RoomNotificationSettingsEvents { data class RoomNotificationModeChanged(val mode: RoomNotificationMode) : RoomNotificationSettingsEvents data class SetNotificationMode(val isDefault: Boolean): RoomNotificationSettingsEvents + data object DeleteCustomNotification: RoomNotificationSettingsEvents + data object ClearSetNotificationError: RoomNotificationSettingsEvents + data object ClearRestoreDefaultError: RoomNotificationSettingsEvents } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsNode.kt index 224f850e28..8ccdd3b158 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsNode.kt @@ -22,10 +22,13 @@ import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins import dagger.assisted.Assisted import dagger.assisted.AssistedInject import im.vector.app.features.analytics.plan.MobileScreen import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.RoomScope import io.element.android.services.analytics.api.AnalyticsService @@ -33,10 +36,24 @@ import io.element.android.services.analytics.api.AnalyticsService class RoomNotificationSettingsNode @AssistedInject constructor( @Assisted buildContext: BuildContext, @Assisted plugins: List, - private val presenter: RoomNotificationSettingsPresenter, + presenterFactory: RoomNotificationSettingsPresenter.Factory, private val analyticsService: AnalyticsService, ) : Node(buildContext, plugins = plugins) { + data class RoomNotificationSettingInput( + val showUserDefinedSettingStyle: Boolean + ) : NodeInputs + interface Callback : Plugin { + fun openGlobalNotificationSettings() + } + private val inputs = inputs() + private val callbacks = plugins() + + private fun openGlobalNotificationSettings() { + callbacks.forEach { it.openGlobalNotificationSettings() } + } + + private val presenter = presenterFactory.create(inputs.showUserDefinedSettingStyle) init { lifecycle.subscribe( onResume = { @@ -51,6 +68,7 @@ class RoomNotificationSettingsNode @AssistedInject constructor( RoomNotificationSettingsView( state = state, modifier = modifier, + onShowGlobalNotifications = this::openGlobalNotificationSettings, onBackPressed = this::navigateUp, ) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOptions.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOptions.kt new file mode 100644 index 0000000000..878632db9c --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOptions.kt @@ -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.features.roomdetails.impl.notificationsettings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import io.element.android.libraries.matrix.api.room.RoomNotificationMode + +@Composable +fun RoomNotificationSettingsOptions( + selected: RoomNotificationMode?, + enabled: Boolean, + modifier: Modifier = Modifier, + onOptionSelected: (RoomNotificationSettingsItem) -> Unit = {}, +) { + val items = roomNotificationSettingsItems() + Column(modifier = modifier.selectableGroup()) { + items.forEach { item -> + RoomNotificationSettingsOption( + roomNotificationSettingsItem = item, + isSelected = selected == item.mode, + onOptionSelected = onOptionSelected, + enabled = enabled + ) + } + } +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsPresenter.kt index a6e2477bcc..a75ea94e39 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsPresenter.kt @@ -19,90 +19,176 @@ package io.element.android.features.roomdetails.impl.notificationsettings import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.RoomNotificationMode -import io.element.android.libraries.matrix.api.room.roomNotificationSettings +import io.element.android.libraries.matrix.api.room.RoomNotificationSettings import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import javax.inject.Inject import kotlin.time.Duration.Companion.seconds -class RoomNotificationSettingsPresenter @Inject constructor( +class RoomNotificationSettingsPresenter @AssistedInject constructor( private val room: MatrixRoom, private val notificationSettingsService: NotificationSettingsService, + @Assisted private val showUserDefinedSettingStyle: Boolean, ) : Presenter { + @AssistedFactory + interface Factory { + fun create(showUserDefinedSettingStyle: Boolean): RoomNotificationSettingsPresenter + } + @Composable override fun present(): RoomNotificationSettingsState { val defaultRoomNotificationMode: MutableState = rememberSaveable { mutableStateOf(null) } val localCoroutineScope = rememberCoroutineScope() + val setNotificationSettingAction: MutableState> = remember { mutableStateOf(Async.Uninitialized) } + val restoreDefaultAction: MutableState> = remember { mutableStateOf(Async.Uninitialized) } + + val roomNotificationSettings: MutableState> = remember { + mutableStateOf(Async.Uninitialized) + } + + // We store state of which mode the user has set via the notification service before the new push settings have been updated. + // We show this state immediately to the user and debounce updates to notification settings to hide some invalid states returned + // by the rust sdk during these two events that cause the radio buttons ot toggle quickly back and forth. + // This is a client side work-around until bulk push rule updates are supported. + // ref: https://github.com/matrix-org/matrix-spec-proposals/pull/3934 + val pendingRoomNotificationMode: MutableState = remember { + mutableStateOf(null) + } + + // We store state of whether the user has set the notifications settings to default or custom via the notification service. + // We show this state immediately to the user and debounce updates to notification settings to hide some invalid states returned + // by the rust sdk during these two events that cause the switch ot toggle quickly back and forth. + // This is a client side work-around until bulk push rule updates are supported. + // ref: https://github.com/matrix-org/matrix-spec-proposals/pull/3934 + val pendingSetDefault: MutableState = remember { + mutableStateOf(null) + } LaunchedEffect(Unit) { getDefaultRoomNotificationMode(defaultRoomNotificationMode) - observeNotificationSettings() + fetchNotificationSettings(pendingRoomNotificationMode, roomNotificationSettings) + observeNotificationSettings(pendingRoomNotificationMode, roomNotificationSettings) } - val roomNotificationSettingsState by room.roomNotificationSettingsStateFlow.collectAsState() - fun handleEvents(event: RoomNotificationSettingsEvents) { when (event) { is RoomNotificationSettingsEvents.RoomNotificationModeChanged -> { - localCoroutineScope.setRoomNotificationMode(event.mode) + localCoroutineScope.setRoomNotificationMode(event.mode, pendingRoomNotificationMode, pendingSetDefault, setNotificationSettingAction) } is RoomNotificationSettingsEvents.SetNotificationMode -> { if (event.isDefault) { - localCoroutineScope.restoreDefaultRoomNotificationMode() + localCoroutineScope.restoreDefaultRoomNotificationMode(restoreDefaultAction, pendingSetDefault) } else { defaultRoomNotificationMode.value?.let { - localCoroutineScope.setRoomNotificationMode(it) + localCoroutineScope.setRoomNotificationMode(it, pendingRoomNotificationMode, pendingSetDefault, setNotificationSettingAction) } } } + is RoomNotificationSettingsEvents.DeleteCustomNotification -> { + localCoroutineScope.restoreDefaultRoomNotificationMode(restoreDefaultAction, pendingSetDefault) + } + RoomNotificationSettingsEvents.ClearSetNotificationError -> { + setNotificationSettingAction.value = Async.Uninitialized + } + RoomNotificationSettingsEvents.ClearRestoreDefaultError -> { + restoreDefaultAction.value = Async.Uninitialized + } } } return RoomNotificationSettingsState( - roomNotificationSettings = roomNotificationSettingsState.roomNotificationSettings(), + showUserDefinedSettingStyle = showUserDefinedSettingStyle, + roomName = room.displayName, + roomNotificationSettings = roomNotificationSettings.value, + pendingRoomNotificationMode = pendingRoomNotificationMode.value, + pendingSetDefault = pendingSetDefault.value, defaultRoomNotificationMode = defaultRoomNotificationMode.value, + setNotificationSettingAction = setNotificationSettingAction.value, + restoreDefaultAction = restoreDefaultAction.value, eventSink = ::handleEvents, ) } @OptIn(FlowPreview::class) - private fun CoroutineScope.observeNotificationSettings() { + private fun CoroutineScope.observeNotificationSettings( + pendingModeState: MutableState, + roomNotificationSettings: MutableState> + ) { notificationSettingsService.notificationSettingsChangeFlow .debounce(0.5.seconds) .onEach { - room.updateRoomNotificationSettings() + fetchNotificationSettings(pendingModeState, roomNotificationSettings) } .launchIn(this) } - private fun CoroutineScope.getDefaultRoomNotificationMode(defaultRoomNotificationMode: MutableState) = launch { + private fun CoroutineScope.fetchNotificationSettings( + pendingModeState: MutableState, + roomNotificationSettings: MutableState> + ) = launch { + suspend { + pendingModeState.value = null + notificationSettingsService.getRoomNotificationSettings(room.roomId, room.isEncrypted, room.isOneToOne).getOrThrow() + }.runCatchingUpdatingState(roomNotificationSettings) + } + + private fun CoroutineScope.getDefaultRoomNotificationMode( + defaultRoomNotificationMode: MutableState + ) = launch { defaultRoomNotificationMode.value = notificationSettingsService.getDefaultRoomNotificationMode( room.isEncrypted, room.isOneToOne ).getOrThrow() } - private fun CoroutineScope.setRoomNotificationMode(mode: RoomNotificationMode) = launch { - notificationSettingsService.setRoomNotificationMode(room.roomId, mode) + private fun CoroutineScope.setRoomNotificationMode( + mode: RoomNotificationMode, + pendingModeState: MutableState, + pendingDefaultState: MutableState, + action: MutableState> + ) = launch { + suspend { + pendingModeState.value = mode + pendingDefaultState.value = false + val result = notificationSettingsService.setRoomNotificationMode(room.roomId, mode) + if (result.isFailure) { + pendingModeState.value = null + pendingDefaultState.value = null + } + result.getOrThrow() + }.runCatchingUpdatingState(action) } - private fun CoroutineScope.restoreDefaultRoomNotificationMode() = launch { - notificationSettingsService.restoreDefaultRoomNotificationMode(room.roomId) + private fun CoroutineScope.restoreDefaultRoomNotificationMode( + action: MutableState>, + pendingDefaultState: MutableState + ) = launch { + suspend { + pendingDefaultState.value = true + val result = notificationSettingsService.restoreDefaultRoomNotificationMode(room.roomId) + if (result.isFailure) { + pendingDefaultState.value = null + } + result.getOrThrow() + }.runCatchingUpdatingState(action) } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsState.kt index 04742781b5..ea51636eb3 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsState.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsState.kt @@ -16,11 +16,26 @@ package io.element.android.features.roomdetails.impl.notificationsettings +import io.element.android.libraries.architecture.Async import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationSettings data class RoomNotificationSettingsState( - val roomNotificationSettings: RoomNotificationSettings?, + val showUserDefinedSettingStyle: Boolean, + val roomName: String, + val roomNotificationSettings: Async, + val pendingRoomNotificationMode: RoomNotificationMode?, + val pendingSetDefault: Boolean?, val defaultRoomNotificationMode: RoomNotificationMode?, + val setNotificationSettingAction: Async, + val restoreDefaultAction: Async, val eventSink: (RoomNotificationSettingsEvents) -> Unit ) + +val RoomNotificationSettingsState.displayNotificationMode: RoomNotificationMode? get() { + return pendingRoomNotificationMode ?: roomNotificationSettings.dataOrNull()?.mode +} + +val RoomNotificationSettingsState.displayIsDefault: Boolean? get() { + return pendingSetDefault ?: roomNotificationSettings.dataOrNull()?.isDefault +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsStateProvider.kt index df1dd7977b..2df0edede7 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsStateProvider.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsStateProvider.kt @@ -17,18 +17,38 @@ package io.element.android.features.roomdetails.impl.notificationsettings import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.Async import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationSettings internal class RoomNotificationSettingsStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( - RoomNotificationSettingsState( - RoomNotificationSettings( - mode = RoomNotificationMode.MUTE, - isDefault = true), - RoomNotificationMode.ALL_MESSAGES, - eventSink = { }, - ), + aRoomNotificationSettingsState(), + aRoomNotificationSettingsState(isDefault = false), + aRoomNotificationSettingsState(setNotificationSettingAction = Async.Loading(Unit)), + aRoomNotificationSettingsState(setNotificationSettingAction = Async.Failure(Throwable("error"))), + aRoomNotificationSettingsState(restoreDefaultAction = Async.Loading(Unit)), + aRoomNotificationSettingsState(restoreDefaultAction = Async.Failure(Throwable("error"))), ) + + private fun aRoomNotificationSettingsState( + isDefault: Boolean = true, + setNotificationSettingAction: Async = Async.Uninitialized, + restoreDefaultAction: Async = Async.Uninitialized, + ): RoomNotificationSettingsState { + return RoomNotificationSettingsState( + showUserDefinedSettingStyle = false, + roomName = "Room 1", + Async.Success(RoomNotificationSettings( + mode = RoomNotificationMode.MUTE, + isDefault = isDefault)), + pendingRoomNotificationMode = null, + pendingSetDefault = null, + defaultRoomNotificationMode = RoomNotificationMode.ALL_MESSAGES, + setNotificationSettingAction = setNotificationSettingAction, + restoreDefaultAction = restoreDefaultAction, + eventSink = { }, + ) + } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt index 8234582c9c..0b9a1fd5f9 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt @@ -18,25 +18,29 @@ package io.element.android.features.roomdetails.impl.notificationsettings import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.features.roomdetails.impl.R +import io.element.android.libraries.architecture.Async import io.element.android.libraries.core.bool.orTrue +import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch -import io.element.android.libraries.designsystem.components.preferences.PreferenceText -import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart import io.element.android.libraries.designsystem.theme.aliasScreenTitle import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Text @@ -45,11 +49,35 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalLayoutApi::class) + @Composable fun RoomNotificationSettingsView( state: RoomNotificationSettingsState, modifier: Modifier = Modifier, + onShowGlobalNotifications: () -> Unit = {}, + onBackPressed: () -> Unit = {}, +) { + if(state.showUserDefinedSettingStyle) { + UserDefinedRoomNotificationSettingsView( + state = state, + modifier = modifier, + onBackPressed = onBackPressed, + ) + } else { + RoomSpecificNotificationSettingsView( + state = state, + modifier = modifier, + onShowGlobalNotifications = onShowGlobalNotifications, + onBackPressed = onBackPressed, + ) + } +} + +@Composable +private fun RoomSpecificNotificationSettingsView( + state: RoomNotificationSettingsState, + modifier: Modifier = Modifier, + onShowGlobalNotifications: () -> Unit = {}, onBackPressed: () -> Unit = {}, ) { Scaffold( @@ -67,41 +95,84 @@ fun RoomNotificationSettingsView( .consumeWindowInsets(padding), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - val subtitle = when (state.defaultRoomNotificationMode) { - RoomNotificationMode.ALL_MESSAGES -> stringResource(id = R.string.screen_room_notification_settings_mode_all_messages) - RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> stringResource(id = R.string.screen_room_notification_settings_mode_mentions_and_keywords) - RoomNotificationMode.MUTE -> stringResource(id = CommonStrings.common_mute) - null -> "" - } - - - PreferenceCategory(title = stringResource(id = R.string.screen_room_notification_settings_custom_settings_title)) { - PreferenceSwitch( - isChecked = state.roomNotificationSettings?.isDefault.orTrue(), - onCheckedChange = { - state.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(it)) - }, - title = "Match default setting", - subtitle = subtitle, - enabled = state.roomNotificationSettings != null - ) - - PreferenceText( - title = stringResource(id = R.string.screen_room_notification_settings_allow_custom), - subtitle = stringResource(id = R.string.screen_room_notification_settings_allow_custom_footnote), - enabled = state.roomNotificationSettings != null && !state.roomNotificationSettings.isDefault, - ) - - if (state.roomNotificationSettings != null) { + val roomNotificationSettings = state.roomNotificationSettings.dataOrNull() + PreferenceSwitch( + isChecked = !state.displayIsDefault.orTrue(), + onCheckedChange = { + state.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(!it)) + }, + title = stringResource(id = R.string.screen_room_notification_settings_allow_custom), + subtitle = stringResource(id = R.string.screen_room_notification_settings_allow_custom_footnote), + enabled = roomNotificationSettings != null + ) + if (state.displayIsDefault.orTrue()) { + PreferenceCategory(title = stringResource(id = R.string.screen_room_notification_settings_default_setting_title)) { + val text = buildAnnotatedStringWithStyledPart( + R.string.screen_room_notification_settings_default_setting_footnote, + R.string.screen_room_notification_settings_default_setting_footnote_content_link, + color = Color.Unspecified, + underline = false, + bold = true, + ) + ClickableText( + text = text, + onClick = { + onShowGlobalNotifications() + }, + modifier = Modifier + .padding(start = 16.dp, bottom = 16.dp, end = 16.dp), + style = ElementTheme.typography.fontBodyMdRegular + .copy( + color = MaterialTheme.colorScheme.secondary, + textAlign = TextAlign.Center, + ) + ) + if(state.defaultRoomNotificationMode != null){ + val defaultModeTitle = when (state.defaultRoomNotificationMode) { + RoomNotificationMode.ALL_MESSAGES -> stringResource(id = R.string.screen_room_notification_settings_mode_all_messages) + RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> { + stringResource(id = R.string.screen_room_notification_settings_mode_mentions_and_keywords) + } + RoomNotificationMode.MUTE -> stringResource(id = CommonStrings.common_mute) + } + RoomNotificationSettingsOption( + roomNotificationSettingsItem = RoomNotificationSettingsItem(state.defaultRoomNotificationMode, defaultModeTitle), + isSelected = true, + onOptionSelected = { }, + enabled = true + ) + } + } + } else { + PreferenceCategory(title = stringResource(id = R.string.screen_room_notification_settings_custom_settings_title)) { RoomNotificationSettingsOptions( - selected = state.roomNotificationSettings.mode, - enabled = !state.roomNotificationSettings.isDefault, + selected = state.displayNotificationMode, + enabled = !state.displayIsDefault.orTrue(), onOptionSelected = { state.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(it.mode)) - }, - ) + },) } } + + when (state.setNotificationSettingAction) { + is Async.Loading -> { + ProgressDialog() + } + is Async.Failure -> { + ShowChangeNotificationSettingError(state, RoomNotificationSettingsEvents.ClearSetNotificationError) + } + else -> Unit + } + + when (state.restoreDefaultAction) { + is Async.Loading -> { + ProgressDialog() + } + is Async.Failure -> { + ShowChangeNotificationSettingError(state, RoomNotificationSettingsEvents.ClearRestoreDefaultError) + } + else -> Unit + } } } } @@ -124,26 +195,6 @@ private fun RoomNotificationSettingsTopBar( ) } -@Composable -private fun RoomNotificationSettingsOptions( - selected: RoomNotificationMode?, - enabled: Boolean, - modifier: Modifier = Modifier, - onOptionSelected: (RoomNotificationSettingsItem) -> Unit = {}, -) { - val items = roomNotificationSettingsItems() - Column(modifier = modifier.selectableGroup()) { - items.forEach { item -> - RoomNotificationSettingsOption( - roomNotificationSettingsItem = item, - isSelected = selected == item.mode, - onOptionSelected = onOptionSelected, - enabled = enabled - ) - } - } -} - @PreviewsDayNight @Composable internal fun RoomNotificationSettingsPreview( diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/ShowChangeNotificationSettingError.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/ShowChangeNotificationSettingError.kt new file mode 100644 index 0000000000..4b99976988 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/ShowChangeNotificationSettingError.kt @@ -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. + */ + +package io.element.android.features.roomdetails.impl.notificationsettings + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun ShowChangeNotificationSettingError(state: RoomNotificationSettingsState, event: RoomNotificationSettingsEvents) { + ErrorDialog( + title = stringResource(CommonStrings.dialog_title_error), + content = stringResource(CommonStrings.screen_notification_settings_edit_failed_updating_default_mode), + onDismiss = { state.eventSink(event) }, + ) +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsStateProvider.kt new file mode 100644 index 0000000000..59181f4d9c --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsStateProvider.kt @@ -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.features.roomdetails.impl.notificationsettings + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.api.room.RoomNotificationSettings + +internal class UserDefinedRoomNotificationSettingsStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + RoomNotificationSettingsState( + showUserDefinedSettingStyle = false, + roomName = "Room 1", + Async.Success( + RoomNotificationSettings( + mode = RoomNotificationMode.MUTE, + isDefault = false) + ), + pendingRoomNotificationMode = null, + pendingSetDefault = null, + defaultRoomNotificationMode = RoomNotificationMode.ALL_MESSAGES, + setNotificationSettingAction = Async.Uninitialized, + restoreDefaultAction = Async.Uninitialized, + eventSink = { }, + ), + ) +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt new file mode 100644 index 0000000000..6afde5bbf9 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt @@ -0,0 +1,141 @@ +/* + * 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.features.roomdetails.impl.notificationsettings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.roomdetails.impl.R +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.core.bool.orTrue +import io.element.android.libraries.designsystem.components.ProgressDialog +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.preferences.PreferenceText +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.designsystem.utils.CommonDrawables + +@Composable +fun UserDefinedRoomNotificationSettingsView( + state: RoomNotificationSettingsState, + modifier: Modifier = Modifier, + onBackPressed: () -> Unit = {}, +) { + Scaffold( + modifier = modifier, + topBar = { + UserDefinedRoomNotificationSettingsTopBar( + roomName = state.roomName, + onBackPressed = { onBackPressed() } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(padding) + .consumeWindowInsets(padding), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + val roomNotificationSettings = state.roomNotificationSettings.dataOrNull() + if (roomNotificationSettings != null && state.displayNotificationMode != null) { + RoomNotificationSettingsOptions( + selected = state.displayNotificationMode, + enabled = !state.displayIsDefault.orTrue(), + onOptionSelected = { + state.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(it.mode)) + }, + ) + } + + PreferenceText( + title = stringResource(R.string.screen_room_notification_settings_edit_remove_setting), + icon = ImageVector.vectorResource(CommonDrawables.ic_compound_delete), + tintColor = MaterialTheme.colorScheme.error, + onClick = { + state.eventSink(RoomNotificationSettingsEvents.DeleteCustomNotification) + } + ) + + when (state.setNotificationSettingAction) { + is Async.Loading -> { + ProgressDialog() + } + is Async.Failure -> { + ShowChangeNotificationSettingError(state, RoomNotificationSettingsEvents.ClearSetNotificationError) + } + else -> Unit + } + + when (state.restoreDefaultAction) { + is Async.Loading -> { + ProgressDialog() + } + is Async.Failure -> { + ShowChangeNotificationSettingError(state, RoomNotificationSettingsEvents.ClearRestoreDefaultError) + } + is Async.Success -> { + LaunchedEffect(state.restoreDefaultAction) { + onBackPressed() + } + } + else -> Unit + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun UserDefinedRoomNotificationSettingsTopBar( + roomName: String, + modifier: Modifier = Modifier, + onBackPressed: () -> Unit = {}, +) { + TopAppBar( + modifier = modifier, + title = { + Text( + text = roomName, + ) + }, + navigationIcon = { BackButton(onClick = onBackPressed) }, + ) +} + +@PreviewsDayNight +@Composable +internal fun UserDefinedRoomNotificationSettingsPreview( + @PreviewParameter(UserDefinedRoomNotificationSettingsStateProvider::class) state: RoomNotificationSettingsState +) = ElementPreview { + UserDefinedRoomNotificationSettingsView(state) +} diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/notificationsettings/RoomNotificationSettingsPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/notificationsettings/RoomNotificationSettingsPresenterTests.kt index a1b7831ce2..c4134462f7 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/notificationsettings/RoomNotificationSettingsPresenterTests.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/notificationsettings/RoomNotificationSettingsPresenterTests.kt @@ -24,6 +24,9 @@ import io.element.android.features.roomdetails.aMatrixRoom import io.element.android.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsEvents import io.element.android.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsPresenter import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_THROWABLE +import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService import io.element.android.tests.testutils.consumeItemsUntilPredicate import kotlinx.coroutines.test.runTest import org.junit.Test @@ -31,12 +34,12 @@ import org.junit.Test class RoomNotificationSettingsPresenterTests { @Test fun `present - initial state is created from room info`() = runTest { - val presenter = aNotificationPresenter + val presenter = createRoomNotificationSettingsPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() - Truth.assertThat(initialState.roomNotificationSettings).isNull() + Truth.assertThat(initialState.roomNotificationSettings.dataOrNull()).isNull() Truth.assertThat(initialState.defaultRoomNotificationMode).isNull() cancelAndIgnoreRemainingEvents() } @@ -44,21 +47,80 @@ class RoomNotificationSettingsPresenterTests { @Test fun `present - notification mode changed`() = runTest { - val presenter = aNotificationPresenter + val presenter = createRoomNotificationSettingsPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { awaitItem().eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)) val updatedState = consumeItemsUntilPredicate { - it.roomNotificationSettings?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY + it.roomNotificationSettings.dataOrNull()?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY }.last() - Truth.assertThat(updatedState.roomNotificationSettings?.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY) + Truth.assertThat(updatedState.roomNotificationSettings.dataOrNull()?.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - observe notification mode changed`() = runTest { + val notificationSettingsService = FakeNotificationSettingsService() + val presenter = createRoomNotificationSettingsPresenter(notificationSettingsService) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + notificationSettingsService.setRoomNotificationMode(A_ROOM_ID, RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY) + val updatedState = consumeItemsUntilPredicate { + it.roomNotificationSettings.dataOrNull()?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY + }.last() + Truth.assertThat(updatedState.roomNotificationSettings.dataOrNull()?.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY) + } + } + + @Test + fun `present - notification settings set custom failed`() = runTest { + val notificationSettingsService = FakeNotificationSettingsService() + notificationSettingsService.givenSetNotificationModeError(A_THROWABLE) + val presenter = createRoomNotificationSettingsPresenter(notificationSettingsService) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(false)) + val failedState = consumeItemsUntilPredicate { + it.setNotificationSettingAction.isFailure() + }.last() + + Truth.assertThat(failedState.roomNotificationSettings.dataOrNull()?.isDefault).isTrue() + Truth.assertThat(failedState.pendingSetDefault).isNull() + Truth.assertThat(failedState.setNotificationSettingAction.isFailure()).isTrue() + + failedState.eventSink(RoomNotificationSettingsEvents.ClearSetNotificationError) + + val errorClearedState = consumeItemsUntilPredicate { + it.setNotificationSettingAction.isUninitialized() + }.last() + Truth.assertThat(errorClearedState.setNotificationSettingAction.isUninitialized()).isTrue() + } + } + + @Test + fun `present - notification settings set custom`() = runTest { + val notificationSettingsService = FakeNotificationSettingsService() + val presenter = createRoomNotificationSettingsPresenter(notificationSettingsService) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(false)) + val defaultState = consumeItemsUntilPredicate { + it.roomNotificationSettings.dataOrNull()?.isDefault == false + }.last() + Truth.assertThat(defaultState.roomNotificationSettings.dataOrNull()?.isDefault).isFalse() } } @Test fun `present - notification settings restore default`() = runTest { - val presenter = aNotificationPresenter + val presenter = createRoomNotificationSettingsPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -66,17 +128,45 @@ class RoomNotificationSettingsPresenterTests { initialState.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)) initialState.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(true)) val defaultState = consumeItemsUntilPredicate { - it.roomNotificationSettings?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY + it.roomNotificationSettings.dataOrNull()?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY }.last() - Truth.assertThat(defaultState.roomNotificationSettings?.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY) + Truth.assertThat(defaultState.roomNotificationSettings.dataOrNull()?.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY) + cancelAndIgnoreRemainingEvents() } } - private val aNotificationPresenter: RoomNotificationSettingsPresenter get() { - val room = aMatrixRoom() + @Test + fun `present - notification settings restore default failed`() = runTest { + val notificationSettingsService = FakeNotificationSettingsService() + notificationSettingsService.givenRestoreDefaultNotificationModeError(A_THROWABLE) + val presenter = createRoomNotificationSettingsPresenter(notificationSettingsService) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)) + initialState.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(true)) + val failedState = consumeItemsUntilPredicate { + it.restoreDefaultAction.isFailure() + }.last() + Truth.assertThat(failedState.restoreDefaultAction.isFailure()).isTrue() + failedState.eventSink(RoomNotificationSettingsEvents.ClearRestoreDefaultError) + + val errorClearedState = consumeItemsUntilPredicate { + it.restoreDefaultAction.isUninitialized() + }.last() + Truth.assertThat(errorClearedState.restoreDefaultAction.isUninitialized()).isTrue() + cancelAndIgnoreRemainingEvents() + } + } + private fun createRoomNotificationSettingsPresenter( + notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService() + ): RoomNotificationSettingsPresenter{ + val room = aMatrixRoom(notificationSettingsService = notificationSettingsService) return RoomNotificationSettingsPresenter( room = room, - notificationSettingsService = room.notificationSettingsService + notificationSettingsService = notificationSettingsService, + showUserDefinedSettingStyle = false, ) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1c86738fa0..56e9f0cfbe 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -75,6 +75,7 @@ google_firebase_bom = "com.google.firebase:firebase-bom:32.4.0" # AndroidX androidx_core = { module = "androidx.core:core", version.ref = "core" } androidx_corektx = { module = "androidx.core:core-ktx", version.ref = "core" } +androidx_annotationjvm = { module = "androidx.annotation:annotation-jvm", version = "1.7.0" } androidx_datastore_preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } androidx_datastore_datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } androidx_exifinterface = "androidx.exifinterface:exifinterface:1.3.6" @@ -132,7 +133,7 @@ test_hamcrest = "org.hamcrest:hamcrest:2.2" test_orchestrator = "androidx.test:orchestrator:1.4.2" test_turbine = "app.cash.turbine:turbine:1.0.0" test_truth = "com.google.truth:truth:1.1.5" -test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.13" +test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.14" test_robolectric = "org.robolectric:robolectric:4.10.3" test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = "appyx" } @@ -148,7 +149,7 @@ jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" } molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" } timber = "com.jakewharton.timber:timber:5.0.1" -matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.63" +matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.65" matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" } matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" } sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } @@ -164,6 +165,8 @@ statemachine = "com.freeletics.flowredux:compose:1.2.0" maplibre = "org.maplibre.gl:android-sdk:10.2.0" maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:2.0.1" maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:2.0.1" +opusencoder = "io.element.android:opusencoder:1.1.0" +audiowaveform = "com.github.lincollincol:compose-audiowaveform:1.1.1" # Analytics posthog = "com.posthog.android:posthog:2.0.3" diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/hash/Hash.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/hash/Hash.kt new file mode 100644 index 0000000000..760431a7be --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/hash/Hash.kt @@ -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() +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt index 45d7780393..b2004ed204 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt @@ -46,4 +46,6 @@ enum class AvatarSize(val dp: Dp) { EditRoomDetails(70.dp), NotificationsOptIn(32.dp), + + CustomRoomNotificationSetting(36.dp) } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/BackupState.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/BackupState.kt new file mode 100644 index 0000000000..41506417aa --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/BackupState.kt @@ -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; +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notificationsettings/NotificationSettingsService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notificationsettings/NotificationSettingsService.kt index 5a81edb052..71d46a2b8e 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notificationsettings/NotificationSettingsService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notificationsettings/NotificationSettingsService.kt @@ -38,4 +38,5 @@ interface NotificationSettingsService { suspend fun setRoomMentionEnabled(enabled: Boolean): Result suspend fun isCallEnabled(): Result suspend fun setCallEnabled(enabled: Boolean): Result + suspend fun getRoomsWithUserDefinedRules(): Result> } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingFilterConfiguration.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingFilterConfiguration.kt index 3062fa3aa3..5cd6750552 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingFilterConfiguration.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingFilterConfiguration.kt @@ -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 { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 14cf3cd17e..21a489904b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -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, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/BackupStateMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/BackupStateMapper.kt new file mode 100644 index 0000000000..dbdd48da6c --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/BackupStateMapper.kt @@ -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 + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notificationsettings/RustNotificationSettingsService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notificationsettings/RustNotificationSettingsService.kt index a2fffdbdfb..42b2662a6d 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notificationsettings/RustNotificationSettingsService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notificationsettings/RustNotificationSettingsService.kt @@ -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 = 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> = + runCatching { + notificationSettings.getRoomsWithUserDefinedRules(enabled = true) + } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt index 29797ed3c4..6616539e95 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt @@ -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()) } } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt index cae5df4dc4..8e71447716 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt @@ -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") diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notificationsettings/FakeNotificationSettingsService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notificationsettings/FakeNotificationSettingsService.kt index 77592d6d1f..039b855e7e 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notificationsettings/FakeNotificationSettingsService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notificationsettings/FakeNotificationSettingsService.kt @@ -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 get() = _notificationSettingsStateFlow override suspend fun getRoomNotificationSettings(roomId: RoomId, isEncrypted: Boolean, isOneToOne: Boolean): Result { 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 { + 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 { - 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 { + 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 { + 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> { + 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 + } + } diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt index 899e92efc5..dde62e7513 100644 --- a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt @@ -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, + progressCallback: ProgressCallback? = null + ): Result { + 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.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, diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt index 51f6372b23..e1debf6bda 100644 --- a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt @@ -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) : MediaUploadInfo data class AnyFile(override val file: File, val fileInfo: FileInfo) : MediaUploadInfo } diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/AndroidMediaPreProcessor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/AndroidMediaPreProcessor.kt index cd968530f8..205be3b241 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/AndroidMediaPreProcessor.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/AndroidMediaPreProcessor.kt @@ -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) } } diff --git a/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt index d94414d2d7..8e7e71e8fb 100644 --- a/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt +++ b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt @@ -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) { 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", + ), + ) + ) + ) + } } diff --git a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsStateProvider.kt b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsStateProvider.kt index cc59d96b44..19797b9075 100644 --- a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsStateProvider.kt +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsStateProvider.kt @@ -31,10 +31,11 @@ open class PermissionsStateProvider : PreviewParameterProvider 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, diff --git a/libraries/textcomposer/impl/build.gradle.kts b/libraries/textcomposer/impl/build.gradle.kts index bdc5e2b3c5..db97a96787 100644 --- a/libraries/textcomposer/impl/build.gradle.kts +++ b/libraries/textcomposer/impl/build.gradle.kts @@ -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) diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index 911cebb142..6e774bcda4 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -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>, diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageDeleteButton.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageDeleteButton.kt new file mode 100644 index 0000000000..4d584d674e --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageDeleteButton.kt @@ -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) + } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/RecordingProgress.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessagePreview.kt similarity index 73% rename from libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/RecordingProgress.kt rename to libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessagePreview.kt index 2fc0420e05..2164f8cd6f 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/RecordingProgress.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessagePreview.kt @@ -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) + } } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageRecording.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageRecording.kt new file mode 100644 index 0000000000..99a0e82b7c --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageRecording.kt @@ -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) +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessageState.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessageState.kt index d376c4ee70..590d5f2e50 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessageState.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessageState.kt @@ -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() } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageComposerEvents.kt b/libraries/ui-utils/build.gradle.kts similarity index 68% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageComposerEvents.kt rename to libraries/ui-utils/build.gradle.kts index 7d6803fc41..26759fa9c8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageComposerEvents.kt +++ b/libraries/ui-utils/build.gradle.kts @@ -14,12 +14,15 @@ * limitations under the License. */ -package io.element.android.features.messages.impl.voicemessages - -import io.element.android.libraries.textcomposer.model.PressEvent - -sealed interface VoiceMessageComposerEvents { - data class RecordButtonEvent( - val pressEvent: PressEvent - ): VoiceMessageComposerEvents +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.ui.utils" + + dependencies { + testImplementation(libs.test.junit) + testImplementation(libs.test.truth) + } } diff --git a/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/time/DurationExt.kt b/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/time/DurationExt.kt new file mode 100644 index 0000000000..57f7a22af3 --- /dev/null +++ b/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/time/DurationExt.kt @@ -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" +} diff --git a/libraries/ui-utils/src/test/kotlin/io/element/android/libraries/ui/utils/time/DurationFormatTest.kt b/libraries/ui-utils/src/test/kotlin/io/element/android/libraries/ui/utils/time/DurationFormatTest.kt new file mode 100644 index 0000000000..dce9239b59 --- /dev/null +++ b/libraries/ui-utils/src/test/kotlin/io/element/android/libraries/ui/utils/time/DurationFormatTest.kt @@ -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> { + return arrayListOf( + arrayOf(0, "0:00"), + arrayOf(1, "0:01"), + arrayOf(10, "0:10"), + arrayOf(59.9, "0:59"), + arrayOf(60, "1:00"), + arrayOf(61, "1:01"), + arrayOf(60 * 60, "60:00"), + arrayOf(-60, "-1:00"), + arrayOf(-1, "-0:01"), + ).toList() + } + } + + @Test + fun formatShort() { + assertEquals(output, seconds.seconds.formatShort()) + } +} diff --git a/libraries/voicerecorder/api/build.gradle.kts b/libraries/voicerecorder/api/build.gradle.kts new file mode 100644 index 0000000000..bed69b7d28 --- /dev/null +++ b/libraries/voicerecorder/api/build.gradle.kts @@ -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) +} diff --git a/libraries/voicerecorder/api/src/main/kotlin/io/element/android/libraries/voicerecorder/api/VoiceRecorder.kt b/libraries/voicerecorder/api/src/main/kotlin/io/element/android/libraries/voicerecorder/api/VoiceRecorder.kt new file mode 100644 index 0000000000..77465ddeea --- /dev/null +++ b/libraries/voicerecorder/api/src/main/kotlin/io/element/android/libraries/voicerecorder/api/VoiceRecorder.kt @@ -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 +} diff --git a/libraries/voicerecorder/api/src/main/kotlin/io/element/android/libraries/voicerecorder/api/VoiceRecorderState.kt b/libraries/voicerecorder/api/src/main/kotlin/io/element/android/libraries/voicerecorder/api/VoiceRecorderState.kt new file mode 100644 index 0000000000..6ba1476ac7 --- /dev/null +++ b/libraries/voicerecorder/api/src/main/kotlin/io/element/android/libraries/voicerecorder/api/VoiceRecorderState.kt @@ -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() +} diff --git a/libraries/voicerecorder/impl/build.gradle.kts b/libraries/voicerecorder/impl/build.gradle.kts new file mode 100644 index 0000000000..6ebfb28997 --- /dev/null +++ b/libraries/voicerecorder/impl/build.gradle.kts @@ -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) +} diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/VoiceRecorderImpl.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/VoiceRecorderImpl.kt new file mode 100644 index 0000000000..b150cd1059 --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/VoiceRecorderImpl.kt @@ -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.Idle) + override val state: StateFlow = _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) + } +} diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AndroidAudioReader.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AndroidAudioReader.kt new file mode 100644 index 0000000000..a2342f3c2f --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AndroidAudioReader.kt @@ -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 diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/Audio.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/Audio.kt new file mode 100644 index 0000000000..3e51d615f4 --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/Audio.kt @@ -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() +} diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AudioConfig.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AudioConfig.kt new file mode 100644 index 0000000000..6ff912c2ae --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AudioConfig.kt @@ -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, +) diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AudioLevelCalculator.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AudioLevelCalculator.kt new file mode 100644 index 0000000000..554b6ba4b1 --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AudioLevelCalculator.kt @@ -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 +} diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AudioReader.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AudioReader.kt new file mode 100644 index 0000000000..230c9533fd --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AudioReader.kt @@ -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 + } + +} diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/DecibelAudioLevelCalculator.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/DecibelAudioLevelCalculator.kt new file mode 100644 index 0000000000..8a16acf83b --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/DecibelAudioLevelCalculator.kt @@ -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) + } +} diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/DefaultEncoder.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/DefaultEncoder.kt new file mode 100644 index 0000000000..a888824fe5 --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/DefaultEncoder.kt @@ -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, + 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 + } +} diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/Encoder.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/Encoder.kt new file mode 100644 index 0000000000..67685635aa --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/Encoder.kt @@ -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() +} diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/SampleRate.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/SampleRate.kt new file mode 100644 index 0000000000..b392b6e19f --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/SampleRate.kt @@ -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 +} diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/di/VoiceRecorderModule.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/di/VoiceRecorderModule.kt new file mode 100644 index 0000000000..b21ab48ac3 --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/di/VoiceRecorderModule.kt @@ -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() +} diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/file/DefaultVoiceFileManager.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/file/DefaultVoiceFileManager.kt new file mode 100644 index 0000000000..07ef54991f --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/file/DefaultVoiceFileManager.kt @@ -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() + } +} diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/file/VoiceFileConfig.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/file/VoiceFileConfig.kt new file mode 100644 index 0000000000..a7b1f4607d --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/file/VoiceFileConfig.kt @@ -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, +) diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/file/VoiceFileManager.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/file/VoiceFileManager.kt new file mode 100644 index 0000000000..77e85b910e --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/file/VoiceFileManager.kt @@ -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) +} diff --git a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/VoiceRecorderImplTest.kt b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/VoiceRecorderImplTest.kt new file mode 100644 index 0000000000..5c9e0506ab --- /dev/null +++ b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/VoiceRecorderImplTest.kt @@ -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() + } + } +} diff --git a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/DecibelAudioLevelCalculatorTest.kt b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/DecibelAudioLevelCalculatorTest.kt new file mode 100644 index 0000000000..8ffbf1ef8e --- /dev/null +++ b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/DecibelAudioLevelCalculatorTest.kt @@ -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) + } +} diff --git a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioLevelCalculator.kt b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioLevelCalculator.kt new file mode 100644 index 0000000000..1615067f6c --- /dev/null +++ b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioLevelCalculator.kt @@ -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 + } +} diff --git a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReader.kt b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReader.kt new file mode 100644 index 0000000000..fecae4dbd5 --- /dev/null +++ b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReader.kt @@ -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