Merge branch 'develop' into feature/fga/pin_settings

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

View file

@ -187,7 +187,9 @@ class LoggedInFlowNode @AssistedInject constructor(
) : NavTarget ) : NavTarget
@Parcelize @Parcelize
data object Settings : NavTarget data class Settings(
val initialElement: PreferencesEntryPoint.InitialTarget = PreferencesEntryPoint.InitialTarget.Root
) : NavTarget
@Parcelize @Parcelize
data object CreateRoom : NavTarget data object CreateRoom : NavTarget
@ -219,7 +221,7 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
override fun onSettingsClicked() { override fun onSettingsClicked() {
backstack.push(NavTarget.Settings) backstack.push(NavTarget.Settings())
} }
override fun onCreateRoomClicked() { override fun onCreateRoomClicked() {
@ -252,11 +254,15 @@ class LoggedInFlowNode @AssistedInject constructor(
override fun onForwardedToSingleRoom(roomId: RoomId) { override fun onForwardedToSingleRoom(roomId: RoomId) {
coroutineScope.launch { attachRoom(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) val inputs = RoomFlowNode.Inputs(roomId = navTarget.roomId, initialElement = navTarget.initialElement)
createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs, callback)) createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs, callback))
} }
NavTarget.Settings -> { is NavTarget.Settings -> {
val callback = object : PreferencesEntryPoint.Callback { val callback = object : PreferencesEntryPoint.Callback {
override fun onOpenBugReport() { override fun onOpenBugReport() {
plugins<Callback>().forEach { it.onOpenBugReport() } plugins<Callback>().forEach { it.onOpenBugReport() }
@ -265,8 +271,14 @@ class LoggedInFlowNode @AssistedInject constructor(
override fun onVerifyClicked() { override fun onVerifyClicked() {
backstack.push(NavTarget.VerifySession) 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) .callback(callback)
.build() .build()
} }

View file

@ -75,6 +75,7 @@ class RoomLoadedFlowNode @AssistedInject constructor(
interface Callback : Plugin { interface Callback : Plugin {
fun onForwardedToSingleRoom(roomId: RoomId) fun onForwardedToSingleRoom(roomId: RoomId)
fun onOpenGlobalNotificationSettings()
} }
data class Inputs( 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 { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
NavTarget.Messages -> { NavTarget.Messages -> {
@ -147,12 +160,13 @@ class RoomLoadedFlowNode @AssistedInject constructor(
messagesEntryPoint.createNode(this, buildContext, callback) messagesEntryPoint.createNode(this, buildContext, callback)
} }
NavTarget.RoomDetails -> { NavTarget.RoomDetails -> {
val inputs = RoomDetailsEntryPoint.Inputs(RoomDetailsEntryPoint.InitialTarget.RoomDetails) createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomDetails)
roomDetailsEntryPoint.createNode(this, buildContext, inputs, emptyList())
} }
is NavTarget.RoomMemberDetails -> { is NavTarget.RoomMemberDetails -> {
val inputs = RoomDetailsEntryPoint.Inputs(RoomDetailsEntryPoint.InitialTarget.RoomMemberDetails(navTarget.userId)) createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomMemberDetails(navTarget.userId))
roomDetailsEntryPoint.createNode(this, buildContext, inputs, emptyList()) }
NavTarget.RoomNotificationSettings -> {
createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomNotificationSettings)
} }
} }
} }
@ -166,6 +180,9 @@ class RoomLoadedFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data class RoomMemberDetails(val userId: UserId) : NavTarget data class RoomMemberDetails(val userId: UserId) : NavTarget
@Parcelize
data object RoomNotificationSettings : NavTarget
} }
@Composable @Composable

View file

@ -71,14 +71,22 @@ class RoomFlowNodeTest {
var nodeId: String? = null var nodeId: String? = null
override fun createNode( override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): RoomDetailsEntryPoint.NodeBuilder {
parentNode: Node, return object : RoomDetailsEntryPoint.NodeBuilder {
buildContext: BuildContext,
inputs: RoomDetailsEntryPoint.Inputs, override fun params(params: RoomDetailsEntryPoint.Params): RoomDetailsEntryPoint.NodeBuilder {
plugins: List<Plugin> return this
): Node { }
return node(buildContext) {}.also {
nodeId = it.id override fun callback(callback: RoomDetailsEntryPoint.Callback): RoomDetailsEntryPoint.NodeBuilder {
return this
}
override fun build(): Node {
return node(buildContext) {}.also {
nodeId = it.id
}
}
} }
} }
} }

View file

@ -198,6 +198,8 @@ koverMerged {
// We do not cover Nodes (normally covered by maestro, but code coverage is not computed with maestro) // We do not cover Nodes (normally covered by maestro, but code coverage is not computed with maestro)
"*Node", "*Node",
"*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.*",
) )
) )
} }

1
changelog.d/1596.feature Normal file
View file

@ -0,0 +1 @@
Record and send voice messages

1
changelog.d/2084.feature Normal file
View file

@ -0,0 +1 @@
Receive and play a voice message

View file

@ -51,6 +51,8 @@ dependencies {
implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.mediaupload.api)
implementation(projects.libraries.permissions.api) implementation(projects.libraries.permissions.api)
implementation(projects.libraries.preferences.api) implementation(projects.libraries.preferences.api)
implementation(projects.libraries.voicerecorder.api)
implementation(projects.libraries.uiUtils)
implementation(projects.features.networkmonitor.api) implementation(projects.features.networkmonitor.api)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
implementation(libs.coil.compose) implementation(libs.coil.compose)
@ -63,6 +65,7 @@ dependencies {
implementation(libs.vanniktech.blurhash) implementation(libs.vanniktech.blurhash)
implementation(libs.telephoto.zoomableimage) implementation(libs.telephoto.zoomableimage)
implementation(libs.matrix.emojibase.bindings) implementation(libs.matrix.emojibase.bindings)
implementation(libs.audiowaveform)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)
@ -80,6 +83,7 @@ dependencies {
testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.textcomposer.test) testImplementation(projects.libraries.textcomposer.test)
testImplementation(projects.libraries.voicerecorder.test)
testImplementation(libs.test.mockk) testImplementation(libs.test.mockk)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ 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.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.RECORD_AUDIO" />
</manifest>

View file

@ -28,9 +28,10 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.messages.impl.attachments.Attachment 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.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories 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.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -49,6 +50,7 @@ class MessagesNode @AssistedInject constructor(
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val presenterFactory: MessagesPresenter.Factory, private val presenterFactory: MessagesPresenter.Factory,
private val timelineItemPresenterFactories: TimelineItemPresenterFactories, private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val mediaPlayer: MediaPlayer,
) : Node(buildContext, plugins = plugins), MessagesNavigator { ) : Node(buildContext, plugins = plugins), MessagesNavigator {
private val presenter = presenterFactory.create(this) private val presenter = presenterFactory.create(this)
@ -71,6 +73,9 @@ class MessagesNode @AssistedInject constructor(
lifecycle.subscribe( lifecycle.subscribe(
onCreate = { onCreate = {
analyticsService.capture(room.toAnalyticsViewRoom()) analyticsService.capture(room.toAnalyticsViewRoom())
},
onDestroy = {
mediaPlayer.close()
} }
) )
} }

View file

@ -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.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent 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.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.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.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.preferences.api.store.PreferencesStore 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.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers 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.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
@ -101,6 +103,7 @@ class MessagesPresenter @AssistedInject constructor(
private val preferencesStore: PreferencesStore, private val preferencesStore: PreferencesStore,
private val featureFlagsService: FeatureFlagService, private val featureFlagsService: FeatureFlagService,
@Assisted private val navigator: MessagesNavigator, @Assisted private val navigator: MessagesNavigator,
private val buildMeta: BuildMeta,
) : Presenter<MessagesState> { ) : Presenter<MessagesState> {
@AssistedFactory @AssistedFactory
@ -203,6 +206,7 @@ class MessagesPresenter @AssistedInject constructor(
enableTextFormatting = enableTextFormatting, enableTextFormatting = enableTextFormatting,
enableVoiceMessages = enableVoiceMessages, enableVoiceMessages = enableVoiceMessages,
enableInRoomCalls = enableInRoomCalls, enableInRoomCalls = enableInRoomCalls,
appName = buildMeta.applicationName,
eventSink = { handleEvents(it) } eventSink = { handleEvents(it) }
) )
} }
@ -328,6 +332,7 @@ class MessagesPresenter @AssistedInject constructor(
type = AttachmentThumbnailType.Location, type = AttachmentThumbnailType.Location,
) )
is TimelineItemPollContent, // TODO Polls: handle reply to is TimelineItemPollContent, // TODO Polls: handle reply to
is TimelineItemVoiceContent, // TODO Voice messages: handle reply to
is TimelineItemTextBasedContent, is TimelineItemTextBasedContent,
is TimelineItemRedactedContent, is TimelineItemRedactedContent,
is TimelineItemStateContent, is TimelineItemStateContent,

View file

@ -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.customreaction.CustomReactionState
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState 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.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.architecture.Async
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
@ -50,5 +50,6 @@ data class MessagesState(
val enableTextFormatting: Boolean, val enableTextFormatting: Boolean,
val enableVoiceMessages: Boolean, val enableVoiceMessages: Boolean,
val enableInRoomCalls: Boolean, val enableInRoomCalls: Boolean,
val appName: String,
val eventSink: (MessagesEvents) -> Unit val eventSink: (MessagesEvents) -> Unit
) )

View file

@ -18,6 +18,7 @@ package io.element.android.features.messages.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.actionlist.anActionListState 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.messagecomposer.aMessageComposerState
import io.element.android.features.messages.impl.timeline.aTimelineItemList import io.element.android.features.messages.impl.timeline.aTimelineItemList
import io.element.android.features.messages.impl.timeline.aTimelineState 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.reactionsummary.ReactionSummaryState
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState 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.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.architecture.Async
import io.element.android.libraries.designsystem.components.avatar.AvatarData 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.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.wysiwyg.compose.RichTextEditorState import io.element.android.wysiwyg.compose.RichTextEditorState
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.persistentSetOf
open class MessagesStateProvider : PreviewParameterProvider<MessagesState> { open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
@ -47,6 +49,20 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
roomAvatar = Async.Uninitialized, roomAvatar = Async.Uninitialized,
), ),
aMessagesState().copy(composerState = aMessageComposerState().copy(showTextFormatting = true)), 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, enableTextFormatting = true,
enableVoiceMessages = true, enableVoiceMessages = true,
enableInRoomCalls = true, enableInRoomCalls = true,
appName = "Element",
eventSink = {} eventSink = {}
) )

View file

@ -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.RetrySendMenuEvents
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMessageMenu 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.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.features.networkmonitor.api.ui.ConnectivityIndicatorView
import io.element.android.libraries.androidutils.ui.hideKeyboard import io.element.android.libraries.androidutils.ui.hideKeyboard
import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule 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.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.designsystem.utils.LogCompositions 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.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
@ -107,6 +110,10 @@ fun MessagesView(
) { ) {
LogCompositions(tag = "MessagesScreen", msg = "Root") LogCompositions(tag = "MessagesScreen", msg = "Root")
OnLifecycleEvent { _, event ->
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.LifecycleEvent(event))
}
AttachmentStateView( AttachmentStateView(
state = state.composerState.attachmentsState, state = state.composerState.attachmentsState,
onPreviewAttachments = onPreviewAttachments, onPreviewAttachments = onPreviewAttachments,
@ -306,6 +313,18 @@ private fun MessagesViewContent(
enableTextFormatting = state.enableTextFormatting, 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( ExpandableBottomSheetScaffold(
sheetDragHandle = if (state.composerState.showTextFormatting) { sheetDragHandle = if (state.composerState.showTextFormatting) {
@Composable { BottomSheetDragHandle() } @Composable { BottomSheetDragHandle() }

View file

@ -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.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent 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.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.canBeCopied
import io.element.android.features.messages.impl.timeline.model.event.canReact import io.element.android.features.messages.impl.timeline.model.event.canReact
import io.element.android.features.preferences.api.store.PreferencesStore 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<TimelineItemAction> { else -> buildList<TimelineItemAction> {
if (timelineItem.isRemote) { if (timelineItem.isRemote) {
// Can only reply or forward messages already uploaded to the server // Can only reply or forward messages already uploaded to the server

View file

@ -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.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent 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.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.features.messages.impl.utils.messagesummary.MessageSummaryFormatterImpl
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize 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) { when (event.content) {
is TimelineItemPollContent, // TODO Polls: handle summary is TimelineItemPollContent, // TODO Polls: handle summary
is TimelineItemVoiceContent, // TODO Voice messages: handle reply summary
is TimelineItemTextBasedContent, is TimelineItemTextBasedContent,
is TimelineItemStateContent, is TimelineItemStateContent,
is TimelineItemEncryptedContent, is TimelineItemEncryptedContent,
@ -348,7 +350,7 @@ private fun EmojiReactionsRow(
) { ) {
// TODO use most recently used emojis here when available from the Rust SDK // TODO use most recently used emojis here when available from the Rust SDK
val defaultEmojis = sequenceOf( val defaultEmojis = sequenceOf(
"👍", "👎", "🔥", "❤️", "👏" "👍", "👎", "🔥", "❤️", "👏"
) )
for (emoji in defaultEmojis) { for (emoji in defaultEmojis) {
val isHighlighted = highlightedEmojis.contains(emoji) val isHighlighted = highlightedEmojis.contains(emoji)

View file

@ -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<State>
/**
* 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<MediaPlayer.State> = _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)
}
}
}
}

View file

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

View file

@ -24,10 +24,10 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerEvents import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerEvents
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerState import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerStateProvider import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerStateProvider
import io.element.android.features.messages.impl.voicemessages.aVoiceMessageComposerState 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.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.textcomposer.model.Message 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)) voiceMessageState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(press))
} }
val onSendVoiceMessage = {
voiceMessageState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
}
val onDeleteVoiceMessage = {
voiceMessageState.eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage)
}
TextComposer( TextComposer(
modifier = modifier, modifier = modifier,
state = state.richTextEditorState, state = state.richTextEditorState,
@ -89,7 +97,9 @@ internal fun MessageComposerView(
onDismissTextFormatting = ::onDismissTextFormatting, onDismissTextFormatting = ::onDismissTextFormatting,
enableTextFormatting = enableTextFormatting, enableTextFormatting = enableTextFormatting,
enableVoiceMessages = enableVoiceMessages, enableVoiceMessages = enableVoiceMessages,
onVoiceRecordButtonEvent = ::onVoiceRecordButtonEvent, onVoiceRecordButtonEvent = onVoiceRecordButtonEvent,
onSendVoiceMessage = onSendVoiceMessage,
onDeleteVoiceMessage = onDeleteVoiceMessage,
onError = ::onError, onError = ::onError,
) )
} }

View file

@ -139,7 +139,7 @@ fun aTimelineItemReactions(
count: Int = 1, count: Int = 1,
isHighlighted: Boolean = false, isHighlighted: Boolean = false,
): TimelineItemReactions { ): TimelineItemReactions {
val emojis = arrayOf("👍", "😀️", "😁️", "😆️", "😅️", "🤣️", "🥰️", "😇️", "😊️", "😉️", "🙃️", "🙂️", "😍️", "🤗️", "🤭️") val emojis = arrayOf("👍", "😀️", "😁️", "😆️", "😅️", "🤣️", "🥰️", "😇️", "😊️", "😉️", "🙃️", "🙂️", "😍️", "🤗️", "🤭️")
return TimelineItemReactions( return TimelineItemReactions(
reactions = buildList { reactions = buildList {
repeat(count) { index -> repeat(count) { index ->

View file

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

View file

@ -17,31 +17,22 @@
package io.element.android.features.messages.impl.timeline.components.customreaction package io.element.android.features.messages.impl.timeline.components.customreaction
import androidx.compose.foundation.ExperimentalFoundationApi 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState 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.SecondaryTabRow
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp 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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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.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.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -101,31 +90,12 @@ fun EmojiPicker(
contentPadding = PaddingValues(vertical = 10.dp, horizontal = 16.dp), contentPadding = PaddingValues(vertical = 10.dp, horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
items(emojis, key = { it.unicode }) { item -> items(emojis, key = { it.unicode }) { item ->
val backgroundColor = if (selectedEmojis.contains(item.unicode)) { EmojiItem(
ElementTheme.colors.bgActionPrimaryRest item = item,
} else { isSelected = selectedEmojis.contains(item.unicode),
Color.Transparent onEmojiSelected = onEmojiSelected
} )
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,
)
}
} }
} }
} }

View file

@ -20,6 +20,8 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import io.element.android.features.messages.impl.timeline.TimelineEvents 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.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent 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.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent 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.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 @Composable
fun TimelineItemEventContentView( fun TimelineItemEventContentView(
@ -44,6 +49,7 @@ fun TimelineItemEventContentView(
eventSink: (TimelineEvents) -> Unit, eventSink: (TimelineEvents) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val presenterFactories = LocalTimelineItemPresenterFactories.current
when (content) { when (content) {
is TimelineItemEncryptedContent -> TimelineItemEncryptedView( is TimelineItemEncryptedContent -> TimelineItemEncryptedView(
content = content, content = content,
@ -100,5 +106,14 @@ fun TimelineItemEventContentView(
eventSink = eventSink, eventSink = eventSink,
modifier = modifier, modifier = modifier,
) )
is TimelineItemVoiceContent -> {
val presenter: Presenter<VoiceMessageState> = presenterFactories.rememberPresenter(content)
TimelineItemVoiceView(
state = presenter.present(),
content = content,
extraPadding = extraPadding,
modifier = modifier
)
}
} }
} }

View file

@ -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<TimelineItemVoiceViewParameters> {
private val voiceMessageStateProvider = VoiceMessageStateProvider()
private val timelineItemVoiceContentProvider = TimelineItemVoiceContentProvider()
override val values: Sequence<TimelineItemVoiceViewParameters>
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,
)
}
}
}

View file

@ -52,7 +52,7 @@ class TimelineItemContentFactory @Inject constructor(
is FailedToParseStateContent -> failedToParseStateFactory.create(itemContent) is FailedToParseStateContent -> failedToParseStateFactory.create(itemContent)
is MessageContent -> { is MessageContent -> {
val senderDisplayName = (eventTimelineItem.senderProfile as? ProfileTimelineDetails.Ready)?.displayName ?: eventTimelineItem.sender.value 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 ProfileChangeContent -> profileChangeFactory.create(eventTimelineItem)
is RedactedContent -> redactedMessageFactory.create(itemContent) is RedactedContent -> redactedMessageFactory.create(itemContent)

View file

@ -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.TimelineItemNoticeContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent 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.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.FileExtensionExtractor
import io.element.android.features.messages.impl.timeline.util.toHtmlDocument import io.element.android.features.messages.impl.timeline.util.toHtmlDocument
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.core.mimetype.MimeTypes 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.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType 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.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownMessageType import io.element.android.libraries.matrix.api.timeline.item.event.UnknownMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType 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 java.time.Duration
import javax.inject.Inject import javax.inject.Inject
class TimelineItemContentMessageFactory @Inject constructor( class TimelineItemContentMessageFactory @Inject constructor(
private val fileSizeFormatter: FileSizeFormatter, private val fileSizeFormatter: FileSizeFormatter,
private val fileExtensionExtractor: FileExtensionExtractor, 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) { return when (val messageType = content.type) {
is EmoteMessageType -> TimelineItemEmoteContent( is EmoteMessageType -> TimelineItemEmoteContent(
body = "* $senderDisplayName ${messageType.body}", body = "* $senderDisplayName ${messageType.body}",
@ -103,14 +110,24 @@ class TimelineItemContentMessageFactory @Inject constructor(
fileExtension = fileExtensionExtractor.extractFromName(messageType.body) fileExtension = fileExtensionExtractor.extractFromName(messageType.body)
) )
} }
is AudioMessageType -> TimelineItemAudioContent( is AudioMessageType -> when {
body = messageType.body, featureFlagService.isFeatureEnabled(FeatureFlags.VoiceMessages) && messageType.isVoiceMessage -> TimelineItemVoiceContent(
mediaSource = messageType.source, eventId = eventId,
duration = messageType.info?.duration ?: Duration.ZERO, body = messageType.body,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream, mediaSource = messageType.source,
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0), duration = messageType.info?.duration ?: Duration.ZERO,
fileExtension = fileExtensionExtractor.extractFromName(messageType.body), 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 -> { is FileMessageType -> {
val fileExtension = fileExtensionExtractor.extractFromName(messageType.body) val fileExtension = fileExtensionExtractor.extractFromName(messageType.body)
TimelineItemFileContent( TimelineItemFileContent(

View file

@ -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.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent 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.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.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
@ -58,6 +59,7 @@ internal fun TimelineItem.Event.canBeGrouped(): Boolean {
is TimelineItemAudioContent, is TimelineItemAudioContent,
is TimelineItemLocationContent, is TimelineItemLocationContent,
is TimelineItemPollContent, is TimelineItemPollContent,
is TimelineItemVoiceContent,
TimelineItemRedactedContent, TimelineItemRedactedContent,
TimelineItemUnknownContent -> false TimelineItemUnknownContent -> false
is TimelineItemProfileChangeContent, is TimelineItemProfileChangeContent,

View file

@ -40,6 +40,7 @@ fun TimelineItemEventContent.canBeCopied(): Boolean =
*/ */
fun TimelineItemEventContent.canBeRepliedTo(): Boolean = fun TimelineItemEventContent.canBeRepliedTo(): Boolean =
when (this) { when (this) {
is TimelineItemVoiceContent, // TODO Voice messages: swipe to reply disabled for now to avoid conflict with audio scrubbing.
is TimelineItemRedactedContent, is TimelineItemRedactedContent,
is TimelineItemStateContent, is TimelineItemStateContent,
is TimelineItemPollContent -> false is TimelineItemPollContent -> false
@ -58,6 +59,7 @@ fun TimelineItemEventContent.canReact(): Boolean =
is TimelineItemImageContent, is TimelineItemImageContent,
is TimelineItemLocationContent, is TimelineItemLocationContent,
is TimelineItemPollContent, is TimelineItemPollContent,
is TimelineItemVoiceContent,
is TimelineItemVideoContent -> true is TimelineItemVideoContent -> true
is TimelineItemStateContent, is TimelineItemStateContent,
is TimelineItemRedactedContent, is TimelineItemRedactedContent,

View file

@ -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<Int>,
) : TimelineItemEventContent {
override val type: String = "TimelineItemAudioContent"
}

View file

@ -0,0 +1,58 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.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<TimelineItemVoiceContent> {
override val values: Sequence<TimelineItemVoiceContent>
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<Int> = 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(),
)

View file

@ -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.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent 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.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.ApplicationContext
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.ui.strings.CommonStrings 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 TimelineItemEncryptedContent -> context.getString(CommonStrings.common_unable_to_decrypt)
is TimelineItemRedactedContent -> context.getString(CommonStrings.common_message_removed) is TimelineItemRedactedContent -> context.getString(CommonStrings.common_message_removed)
is TimelineItemPollContent -> event.content.question is TimelineItemPollContent -> event.content.question
is TimelineItemVoiceContent -> context.getString(CommonStrings.common_voice_message)
is TimelineItemUnknownContent -> context.getString(CommonStrings.common_unsupported_event) is TimelineItemUnknownContent -> context.getString(CommonStrings.common_unsupported_event)
is TimelineItemImageContent -> context.getString(CommonStrings.common_image) is TimelineItemImageContent -> context.getString(CommonStrings.common_image)
is TimelineItemVideoContent -> context.getString(CommonStrings.common_video) is TimelineItemVideoContent -> context.getString(CommonStrings.common_video)

View file

@ -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<VoiceMessageComposerState> {
@Composable
override fun present(): VoiceMessageComposerState {
var voiceMessageState by remember { mutableStateOf<VoiceMessageState>(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) }
)
}
}

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.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()
}

View file

@ -0,0 +1,31 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
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
}

View file

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

View file

@ -14,7 +14,7 @@
* limitations under the License. * 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 androidx.compose.runtime.Stable
import io.element.android.libraries.textcomposer.model.VoiceMessageState import io.element.android.libraries.textcomposer.model.VoiceMessageState
@ -22,6 +22,7 @@ import io.element.android.libraries.textcomposer.model.VoiceMessageState
@Stable @Stable
data class VoiceMessageComposerState( data class VoiceMessageComposerState(
val voiceMessageState: VoiceMessageState, val voiceMessageState: VoiceMessageState,
val showPermissionRationaleDialog: Boolean,
val eventSink: (VoiceMessageComposerEvents) -> Unit, val eventSink: (VoiceMessageComposerEvents) -> Unit,
) )

View file

@ -14,21 +14,24 @@
* limitations under the License. * 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 androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.textcomposer.model.VoiceMessageState import io.element.android.libraries.textcomposer.model.VoiceMessageState
import kotlin.time.Duration.Companion.seconds
internal open class VoiceMessageComposerStateProvider : PreviewParameterProvider<VoiceMessageComposerState> { internal open class VoiceMessageComposerStateProvider : PreviewParameterProvider<VoiceMessageComposerState> {
override val values: Sequence<VoiceMessageComposerState> override val values: Sequence<VoiceMessageComposerState>
get() = sequenceOf( get() = sequenceOf(
aVoiceMessageComposerState(voiceMessageState = VoiceMessageState.Recording), aVoiceMessageComposerState(voiceMessageState = VoiceMessageState.Recording(duration = 61.seconds, level = 0.5)),
) )
} }
internal fun aVoiceMessageComposerState( internal fun aVoiceMessageComposerState(
voiceMessageState: VoiceMessageState = VoiceMessageState.Idle, voiceMessageState: VoiceMessageState = VoiceMessageState.Idle,
showPermissionRationaleDialog: Boolean = false,
) = VoiceMessageComposerState( ) = VoiceMessageComposerState(
voiceMessageState = voiceMessageState, voiceMessageState = voiceMessageState,
showPermissionRationaleDialog = showPermissionRationaleDialog,
eventSink = {}, eventSink = {},
) )

View file

@ -0,0 +1,37 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.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),
)
}

View file

@ -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 "<server-name>/<media-id>".
* @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])
}
}

View file

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

View file

@ -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<State>
/**
* 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<VoiceMessagePlayer.State> = 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()
}
}

View file

@ -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<VoiceMessageState> {
@AssistedFactory
fun interface Factory : TimelineItemPresenterFactory<TimelineItemVoiceContent, VoiceMessageState> {
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<MediaFile>>(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) },
)
}
}

View file

@ -0,0 +1,32 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.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,
}
}

View file

@ -0,0 +1,55 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.voicemessages.timeline
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class VoiceMessageStateProvider : PreviewParameterProvider<VoiceMessageState> {
override val values: Sequence<VoiceMessageState>
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 = {}
),
)
}

View file

@ -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<Int>,
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<Int>.scaleAmplitudes(): List<Int> {
val maxAmplitude = if (isEmpty()) 1 else maxOf { it }
val scalingFactor = 128 / maxAmplitude.toFloat()
return map { (it * scalingFactor).toInt() }
}

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M16,19C15.45,19 14.979,18.804 14.587,18.413C14.196,18.021 14,17.55 14,17V7C14,6.45 14.196,5.979 14.587,5.588C14.979,5.196 15.45,5 16,5C16.55,5 17.021,5.196 17.413,5.588C17.804,5.979 18,6.45 18,7V17C18,17.55 17.804,18.021 17.413,18.413C17.021,18.804 16.55,19 16,19ZM8,19C7.45,19 6.979,18.804 6.588,18.413C6.196,18.021 6,17.55 6,17V7C6,6.45 6.196,5.979 6.588,5.588C6.979,5.196 7.45,5 8,5C8.55,5 9.021,5.196 9.413,5.588C9.804,5.979 10,6.45 10,7V17C10,17.55 9.804,18.021 9.413,18.413C9.021,18.804 8.55,19 8,19Z"
android:fillColor="#656D77"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M9.525,18.025C9.192,18.242 8.854,18.254 8.512,18.063C8.171,17.871 8,17.575 8,17.175V6.825C8,6.425 8.171,6.129 8.512,5.938C8.854,5.746 9.192,5.759 9.525,5.975L17.675,11.15C17.975,11.35 18.125,11.634 18.125,12C18.125,12.367 17.975,12.65 17.675,12.85L9.525,18.025Z"
android:fillColor="#656D77"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,20C9.767,20 7.875,19.225 6.325,17.675C4.775,16.125 4,14.233 4,12C4,9.767 4.775,7.875 6.325,6.325C7.875,4.775 9.767,4 12,4C13.15,4 14.25,4.238 15.3,4.713C16.35,5.188 17.25,5.867 18,6.75V5C18,4.717 18.096,4.479 18.288,4.287C18.479,4.096 18.717,4 19,4C19.283,4 19.521,4.096 19.712,4.287C19.904,4.479 20,4.717 20,5V10C20,10.283 19.904,10.521 19.712,10.712C19.521,10.904 19.283,11 19,11H14C13.717,11 13.479,10.904 13.288,10.712C13.096,10.521 13,10.283 13,10C13,9.717 13.096,9.479 13.288,9.288C13.479,9.096 13.717,9 14,9H17.2C16.667,8.067 15.938,7.333 15.012,6.8C14.087,6.267 13.083,6 12,6C10.333,6 8.917,6.583 7.75,7.75C6.583,8.917 6,10.333 6,12C6,13.667 6.583,15.083 7.75,16.25C8.917,17.417 10.333,18 12,18C13.133,18 14.171,17.712 15.113,17.138C16.054,16.563 16.783,15.792 17.3,14.825C17.433,14.592 17.621,14.429 17.862,14.337C18.104,14.246 18.35,14.242 18.6,14.325C18.867,14.408 19.058,14.583 19.175,14.85C19.292,15.117 19.283,15.367 19.15,15.6C18.467,16.933 17.492,18 16.225,18.8C14.958,19.6 13.55,20 12,20Z"
android:fillColor="#656D77"/>
</vector>

View file

@ -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.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent 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.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.media.FakeLocalMediaFactory
import io.element.android.features.messages.textcomposer.TestRichTextEditorStateFactory import io.element.android.features.messages.textcomposer.TestRichTextEditorStateFactory
import io.element.android.features.messages.timeline.components.customreaction.FakeEmojibaseProvider 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_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_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.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.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomMember import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.mediapickers.test.FakePickerProvider 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.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.libraries.textcomposer.model.MessageComposerMode 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.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.consumeItemsUntilPredicate
@ -607,20 +609,28 @@ class MessagesPresenterTest {
analyticsService: FakeAnalyticsService = FakeAnalyticsService(), analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(), permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
): MessagesPresenter { ): MessagesPresenter {
val mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom)
val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter)
val messageComposerPresenter = MessageComposerPresenter( val messageComposerPresenter = MessageComposerPresenter(
appCoroutineScope = this, appCoroutineScope = this,
room = matrixRoom, room = matrixRoom,
mediaPickerProvider = FakePickerProvider(), mediaPickerProvider = FakePickerProvider(),
featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.NotificationSettings.key to true)), featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.NotificationSettings.key to true)),
localMediaFactory = FakeLocalMediaFactory(mockMediaUrl), localMediaFactory = FakeLocalMediaFactory(mockMediaUrl),
mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom), mediaSender = mediaSender,
snackbarDispatcher = SnackbarDispatcher(), snackbarDispatcher = SnackbarDispatcher(),
analyticsService = analyticsService, analyticsService = analyticsService,
messageComposerContext = MessageComposerContextImpl(), messageComposerContext = MessageComposerContextImpl(),
richTextEditorStateFactory = TestRichTextEditorStateFactory(), richTextEditorStateFactory = TestRichTextEditorStateFactory(),
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter), permissionsPresenterFactory = permissionsPresenterFactory,
)
val voiceMessageComposerPresenter = VoiceMessageComposerPresenter(
this,
FakeVoiceRecorder(),
analyticsService,
mediaSender,
permissionsPresenterFactory,
) )
val voiceMessageComposerPresenter = VoiceMessageComposerPresenter()
val timelinePresenter = TimelinePresenter( val timelinePresenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(), timelineItemsFactory = aTimelineItemsFactory(),
room = matrixRoom, room = matrixRoom,
@ -649,6 +659,7 @@ class MessagesPresenterTest {
clipboardHelper = clipboardHelper, clipboardHelper = clipboardHelper,
preferencesStore = preferencesStore, preferencesStore = preferencesStore,
featureFlagsService = FakeFeatureFlagService(), featureFlagsService = FakeFeatureFlagService(),
buildMeta = aBuildMeta(),
dispatchers = coroutineDispatchers, dispatchers = coroutineDispatchers,
) )
} }

View file

@ -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.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent 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.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.featureflag.test.InMemoryPreferencesStore
import io.element.android.libraries.matrix.test.A_MESSAGE import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
@ -458,6 +459,33 @@ class ActionListPresenterTest {
assertThat(successState.displayEmojiReactions).isTrue() 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 { private fun createActionListPresenter(isDeveloperModeEnabled: Boolean): ActionListPresenter {

View file

@ -49,7 +49,11 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
dispatchers = testCoroutineDispatchers(), dispatchers = testCoroutineDispatchers(),
eventItemFactory = TimelineItemEventFactory( eventItemFactory = TimelineItemEventFactory(
contentFactory = TimelineItemContentFactory( contentFactory = TimelineItemContentFactory(
messageFactory = TimelineItemContentMessageFactory(FakeFileSizeFormatter(), FileExtensionExtractorWithoutValidation()), messageFactory = TimelineItemContentMessageFactory(
fileSizeFormatter = FakeFileSizeFormatter(),
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
featureFlagService = FakeFeatureFlagService(),
),
redactedMessageFactory = TimelineItemContentRedactedFactory(), redactedMessageFactory = TimelineItemContentRedactedFactory(),
stickerFactory = TimelineItemContentStickerFactory(), stickerFactory = TimelineItemContentStickerFactory(),
pollFactory = TimelineItemContentPollFactory(matrixClient, FakeFeatureFlagService()), pollFactory = TimelineItemContentPollFactory(matrixClient, FakeFeatureFlagService()),

View file

@ -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<MediaPlayer.State> = _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
}
}

View file

@ -18,72 +18,431 @@
package io.element.android.features.messages.voicemessages package io.element.android.features.messages.voicemessages
import android.Manifest
import androidx.lifecycle.Lifecycle
import app.cash.molecule.RecompositionMode import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerEvents import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerEvents
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.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.PressEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageState 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 io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import kotlin.time.Duration.Companion.seconds
class VoiceMessageComposerPresenterTest { class VoiceMessageComposerPresenterTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() 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 @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = createPresenter() val presenter = createVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
voiceRecorder.assertCalls(started = 0)
testPauseAndDestroy(initialState)
} }
} }
@Test @Test
fun `present - recording state`() = runTest { fun `present - recording state`() = runTest {
val presenter = createPresenter() val presenter = createVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) 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 @Test
fun `present - abort recording`() = runTest { fun `present - abort recording`() = runTest {
val presenter = createPresenter() val presenter = createVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.Tapped)) 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 @Test
fun `present - finish recording`() = runTest { fun `present - finish recording`() = runTest {
val presenter = createPresenter() val presenter = createVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd)) 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<VoiceMessageComposerState>.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
)
}
} }

View file

@ -0,0 +1,49 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.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
}
}

View file

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

View file

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

View file

@ -15,6 +15,7 @@
*/ */
plugins { plugins {
id("io.element.android-library") id("io.element.android-library")
id("kotlin-parcelize")
} }
android { android {

View file

@ -16,16 +16,30 @@
package io.element.android.features.preferences.api package io.element.android.features.preferences.api
import android.os.Parcelable
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint 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 { 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 fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface NodeBuilder { interface NodeBuilder {
fun params(params: Params): NodeBuilder
fun callback(callback: Callback): NodeBuilder fun callback(callback: Callback): NodeBuilder
fun build(): Node fun build(): Node
} }
@ -33,5 +47,6 @@ interface PreferencesEntryPoint : FeatureEntryPoint {
interface Callback : Plugin { interface Callback : Plugin {
fun onOpenBugReport() fun onOpenBugReport()
fun onVerifyClicked() fun onVerifyClicked()
fun onOpenRoomNotificationSettings(roomId: RoomId)
} }
} }

View file

@ -31,6 +31,11 @@ class DefaultPreferencesEntryPoint @Inject constructor() : PreferencesEntryPoint
return object : PreferencesEntryPoint.NodeBuilder { return object : PreferencesEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>() val plugins = ArrayList<Plugin>()
override fun params(params: PreferencesEntryPoint.Params): PreferencesEntryPoint.NodeBuilder {
plugins += params
return this
}
override fun callback(callback: PreferencesEntryPoint.Callback): PreferencesEntryPoint.NodeBuilder { override fun callback(callback: PreferencesEntryPoint.Callback): PreferencesEntryPoint.NodeBuilder {
plugins += callback plugins += callback
return this 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
}

View file

@ -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.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope 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 io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ -54,7 +55,7 @@ class PreferencesFlowNode @AssistedInject constructor(
private val lockScreenEntryPoint: LockScreenEntryPoint, private val lockScreenEntryPoint: LockScreenEntryPoint,
) : BackstackNode<PreferencesFlowNode.NavTarget>( ) : BackstackNode<PreferencesFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Root, initialElement = plugins.filterIsInstance<PreferencesEntryPoint.Params>().first().initialElement.toNavTarget(),
savedStateMap = buildContext.savedStateMap, savedStateMap = buildContext.savedStateMap,
), ),
buildContext = buildContext, buildContext = buildContext,
@ -161,8 +162,13 @@ class PreferencesFlowNode @AssistedInject constructor(
createNode<NotificationSettingsNode>(buildContext, listOf(notificationSettingsCallback)) createNode<NotificationSettingsNode>(buildContext, listOf(notificationSettingsCallback))
} }
is NavTarget.EditDefaultNotificationSetting -> { is NavTarget.EditDefaultNotificationSetting -> {
val callback = object : EditDefaultNotificationSettingNode.Callback {
override fun openRoomNotificationSettings(roomId: RoomId) {
plugins<PreferencesEntryPoint.Callback>().forEach { it.onOpenRoomNotificationSettings(roomId) }
}
}
val input = EditDefaultNotificationSettingNode.Inputs(navTarget.isOneToOne) val input = EditDefaultNotificationSettingNode.Inputs(navTarget.isOneToOne)
createNode<EditDefaultNotificationSettingNode>(buildContext, plugins = listOf(input)) createNode<EditDefaultNotificationSettingNode>(buildContext, plugins = listOf(input, callback))
} }
NavTarget.AdvancedSettings -> { NavTarget.AdvancedSettings -> {
createNode<AdvancedSettingsNode>(buildContext) createNode<AdvancedSettingsNode>(buildContext)

View file

@ -24,4 +24,5 @@ sealed interface NotificationSettingsEvents {
data class SetCallNotificationsEnabled(val enabled: Boolean) : NotificationSettingsEvents data class SetCallNotificationsEnabled(val enabled: Boolean) : NotificationSettingsEvents
data object FixConfigurationMismatch : NotificationSettingsEvents data object FixConfigurationMismatch : NotificationSettingsEvents
data object ClearConfigurationMismatchError : NotificationSettingsEvents data object ClearConfigurationMismatchError : NotificationSettingsEvents
data object ClearNotificationChangeError : NotificationSettingsEvents
} }

View file

@ -23,7 +23,9 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope 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.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService 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.RoomNotificationMode
@ -50,6 +52,7 @@ class NotificationSettingsPresenter @Inject constructor(
val systemNotificationsEnabled: MutableState<Boolean> = remember { val systemNotificationsEnabled: MutableState<Boolean> = remember {
mutableStateOf(systemNotificationsEnabledProvider.notificationsEnabled()) mutableStateOf(systemNotificationsEnabledProvider.notificationsEnabled())
} }
val changeNotificationSettingAction: MutableState<Async<Unit>> = remember { mutableStateOf(Async.Uninitialized) }
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val appNotificationsEnabled = userPushStore val appNotificationsEnabled = userPushStore
@ -67,8 +70,12 @@ class NotificationSettingsPresenter @Inject constructor(
fun handleEvents(event: NotificationSettingsEvents) { fun handleEvents(event: NotificationSettingsEvents) {
when (event) { when (event) {
is NotificationSettingsEvents.SetAtRoomNotificationsEnabled -> localCoroutineScope.setAtRoomNotificationsEnabled(event.enabled) is NotificationSettingsEvents.SetAtRoomNotificationsEnabled -> {
is NotificationSettingsEvents.SetCallNotificationsEnabled -> localCoroutineScope.setCallNotificationsEnabled(event.enabled) localCoroutineScope.setAtRoomNotificationsEnabled(event.enabled, changeNotificationSettingAction)
}
is NotificationSettingsEvents.SetCallNotificationsEnabled -> {
localCoroutineScope.setCallNotificationsEnabled(event.enabled, changeNotificationSettingAction)
}
is NotificationSettingsEvents.SetNotificationsEnabled -> localCoroutineScope.setNotificationsEnabled(userPushStore, event.enabled) is NotificationSettingsEvents.SetNotificationsEnabled -> localCoroutineScope.setNotificationsEnabled(userPushStore, event.enabled)
NotificationSettingsEvents.ClearConfigurationMismatchError -> { NotificationSettingsEvents.ClearConfigurationMismatchError -> {
matrixSettings.value = NotificationSettingsState.MatrixSettings.Invalid(fixFailed = false) matrixSettings.value = NotificationSettingsState.MatrixSettings.Invalid(fixFailed = false)
@ -77,6 +84,7 @@ class NotificationSettingsPresenter @Inject constructor(
NotificationSettingsEvents.RefreshSystemNotificationsEnabled -> { NotificationSettingsEvents.RefreshSystemNotificationsEnabled -> {
systemNotificationsEnabled.value = systemNotificationsEnabledProvider.notificationsEnabled() systemNotificationsEnabled.value = systemNotificationsEnabledProvider.notificationsEnabled()
} }
NotificationSettingsEvents.ClearNotificationChangeError -> changeNotificationSettingAction.value = Async.Uninitialized
} }
} }
@ -86,6 +94,7 @@ class NotificationSettingsPresenter @Inject constructor(
systemNotificationsEnabled = systemNotificationsEnabled.value, systemNotificationsEnabled = systemNotificationsEnabled.value,
appNotificationsEnabled = appNotificationsEnabled.value appNotificationsEnabled = appNotificationsEnabled.value
), ),
changeNotificationSettingAction = changeNotificationSettingAction.value,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }
@ -154,12 +163,16 @@ class NotificationSettingsPresenter @Inject constructor(
) )
} }
private fun CoroutineScope.setAtRoomNotificationsEnabled(enabled: Boolean) = launch { private fun CoroutineScope.setAtRoomNotificationsEnabled(enabled: Boolean, action: MutableState<Async<Unit>>) = launch {
notificationSettingsService.setRoomMentionEnabled(enabled) suspend {
notificationSettingsService.setRoomMentionEnabled(enabled).getOrThrow()
}.runCatchingUpdatingState(action)
} }
private fun CoroutineScope.setCallNotificationsEnabled(enabled: Boolean) = launch { private fun CoroutineScope.setCallNotificationsEnabled(enabled: Boolean, action: MutableState<Async<Unit>>) = launch {
notificationSettingsService.setCallEnabled(enabled) suspend {
notificationSettingsService.setCallEnabled(enabled).getOrThrow()
}.runCatchingUpdatingState(action)
} }
private fun CoroutineScope.setNotificationsEnabled(userPushStore: UserPushStore, enabled: Boolean) = launch { private fun CoroutineScope.setNotificationsEnabled(userPushStore: UserPushStore, enabled: Boolean) = launch {

View file

@ -17,12 +17,14 @@
package io.element.android.features.preferences.impl.notifications package io.element.android.features.preferences.impl.notifications
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
@Immutable @Immutable
data class NotificationSettingsState( data class NotificationSettingsState(
val matrixSettings: MatrixSettings, val matrixSettings: MatrixSettings,
val appSettings: AppSettings, val appSettings: AppSettings,
val changeNotificationSettingAction: Async<Unit>,
val eventSink: (NotificationSettingsEvents) -> Unit, val eventSink: (NotificationSettingsEvents) -> Unit,
) { ) {
sealed interface MatrixSettings { sealed interface MatrixSettings {

View file

@ -17,16 +17,21 @@
package io.element.android.features.preferences.impl.notifications package io.element.android.features.preferences.impl.notifications
import androidx.compose.ui.tooling.preview.PreviewParameterProvider 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.RoomNotificationMode
open class NotificationSettingsStateProvider : PreviewParameterProvider<NotificationSettingsState> { open class NotificationSettingsStateProvider : PreviewParameterProvider<NotificationSettingsState> {
override val values: Sequence<NotificationSettingsState> override val values: Sequence<NotificationSettingsState>
get() = sequenceOf( get() = sequenceOf(
aNotificationSettingsState(), aNotificationSettingsState(),
aNotificationSettingsState(changeNotificationSettingAction = Async.Loading(Unit)),
aNotificationSettingsState(changeNotificationSettingAction = Async.Failure(Throwable("error"))),
) )
} }
fun aNotificationSettingsState() = NotificationSettingsState( fun aNotificationSettingsState(
changeNotificationSettingAction: Async<Unit> = Async.Uninitialized,
) = NotificationSettingsState(
matrixSettings = NotificationSettingsState.MatrixSettings.Valid( matrixSettings = NotificationSettingsState.MatrixSettings.Valid(
atRoomNotificationsEnabled = true, atRoomNotificationsEnabled = true,
callNotificationsEnabled = true, callNotificationsEnabled = true,
@ -37,5 +42,6 @@ fun aNotificationSettingsState() = NotificationSettingsState(
systemNotificationsEnabled = false, systemNotificationsEnabled = false,
appNotificationsEnabled = true, appNotificationsEnabled = true,
), ),
changeNotificationSettingAction = changeNotificationSettingAction,
eventSink = {} eventSink = {}
) )

View file

@ -34,6 +34,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import io.element.android.libraries.androidutils.system.startNotificationSettingsIntent 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.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory 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.PreferenceSwitch
@ -87,9 +89,23 @@ fun NotificationSettingsView(
onGroupChatsClicked = { onOpenEditDefault(false) }, onGroupChatsClicked = { onOpenEditDefault(false) },
onDirectChatsClicked = { onOpenEditDefault(true) }, onDirectChatsClicked = { onOpenEditDefault(true) },
onMentionNotificationsChanged = { state.eventSink(NotificationSettingsEvents.SetAtRoomNotificationsEnabled(it)) }, 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)) }, // 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, onGroupChatsClicked: () -> Unit,
onDirectChatsClicked: () -> Unit, onDirectChatsClicked: () -> Unit,
onMentionNotificationsChanged: (Boolean) -> Unit, onMentionNotificationsChanged: (Boolean) -> Unit,
// TODO We are removing the call notification toggle until support for call notifications has been added
// onCallsNotificationsChanged: (Boolean) -> Unit, // onCallsNotificationsChanged: (Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -151,7 +168,7 @@ private fun NotificationSettingsContentView(
onCheckedChange = onMentionNotificationsChanged 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)) { // PreferenceCategory(title = stringResource(id = CommonStrings.screen_notification_settings_additional_settings_section_title)) {
// PreferenceSwitch( // PreferenceSwitch(
// modifier = Modifier, // modifier = Modifier,

View file

@ -21,12 +21,14 @@ import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
class EditDefaultNotificationSettingNode @AssistedInject constructor( class EditDefaultNotificationSettingNode @AssistedInject constructor(
@ -35,20 +37,30 @@ class EditDefaultNotificationSettingNode @AssistedInject constructor(
presenterFactory: EditDefaultNotificationSettingPresenter.Factory presenterFactory: EditDefaultNotificationSettingPresenter.Factory
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun openRoomNotificationSettings(roomId: RoomId)
}
data class Inputs( data class Inputs(
val isOneToOne: Boolean val isOneToOne: Boolean
) : NodeInputs ) : NodeInputs
private val inputs = inputs<Inputs>() private val inputs = inputs<Inputs>()
private val callbacks = plugins<Callback>()
private val presenter = presenterFactory.create(inputs.isOneToOne) private val presenter = presenterFactory.create(inputs.isOneToOne)
private fun openRoomNotificationSettings(roomId: RoomId) {
callbacks.forEach { it.openRoomNotificationSettings(roomId) }
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
EditDefaultNotificationSettingView( EditDefaultNotificationSettingView(
state = state, state = state,
openRoomNotificationSettings = { openRoomNotificationSettings(it) },
onBackPressed = ::navigateUp, onBackPressed = ::navigateUp,
modifier = modifier modifier = modifier,
) )
} }
} }

View file

@ -25,20 +25,28 @@ import androidx.compose.runtime.rememberCoroutineScope
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter 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.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.room.RoomNotificationMode 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.CoroutineScope
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.text.Collator
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
class EditDefaultNotificationSettingPresenter @AssistedInject constructor( class EditDefaultNotificationSettingPresenter @AssistedInject constructor(
private val notificationSettingsService: NotificationSettingsService, private val notificationSettingsService: NotificationSettingsService,
@Assisted private val isOneToOne: Boolean, @Assisted private val isOneToOne: Boolean,
private val roomListService: RoomListService,
private val matrixClient: MatrixClient,
) : Presenter<EditDefaultNotificationSettingState> { ) : Presenter<EditDefaultNotificationSettingState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@ -50,21 +58,34 @@ class EditDefaultNotificationSettingPresenter @AssistedInject constructor(
val mode: MutableState<RoomNotificationMode?> = remember { val mode: MutableState<RoomNotificationMode?> = remember {
mutableStateOf(null) mutableStateOf(null)
} }
val changeNotificationSettingAction: MutableState<Async<Unit>> = remember { mutableStateOf(Async.Uninitialized) }
val roomsWithUserDefinedMode: MutableState<List<RoomSummary.Filled>> = remember {
mutableStateOf(listOf())
}
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
fetchSettings(mode) fetchSettings(mode)
observeNotificationSettings(mode) observeNotificationSettings(mode)
observeRoomSummaries(roomsWithUserDefinedMode)
} }
fun handleEvents(event: EditDefaultNotificationSettingStateEvents) { fun handleEvents(event: EditDefaultNotificationSettingStateEvents) {
when (event) { 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( return EditDefaultNotificationSettingState(
isOneToOne = isOneToOne, isOneToOne = isOneToOne,
mode = mode.value, mode = mode.value,
roomsWithUserDefinedMode = roomsWithUserDefinedMode.value,
changeNotificationSettingAction = changeNotificationSettingAction.value,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }
@ -83,10 +104,39 @@ class EditDefaultNotificationSettingPresenter @AssistedInject constructor(
.launchIn(this) .launchIn(this)
} }
private fun CoroutineScope.setDefaultNotificationMode(mode: RoomNotificationMode) = launch { private fun CoroutineScope.observeRoomSummaries(roomsWithUserDefinedMode: MutableState<List<RoomSummary.Filled>>) {
// On modern clients, we don't have different settings for encrypted and non-encrypted rooms (Legacy clients did). roomListService.allRooms()
notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = true, mode = mode, isOneToOne = isOneToOne) .summaries
notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = false, mode = mode, isOneToOne = isOneToOne) .onEach {
updateRoomsWithUserDefinedMode(it, roomsWithUserDefinedMode)
}
.launchIn(this)
}
private fun CoroutineScope.updateRoomsWithUserDefinedMode(
summaries: List<RoomSummary>,
roomsWithUserDefinedMode: MutableState<List<RoomSummary.Filled>>
) = launch {
val roomWithUserDefinedRules: Set<String> = notificationSettingsService.getRoomsWithUserDefinedRules().getOrThrow().toSet()
val sortedSummaries = summaries
.filterIsInstance<RoomSummary.Filled>()
.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<Async<Unit>>) = 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)
} }
} }

View file

@ -16,10 +16,14 @@
package io.element.android.features.preferences.impl.notifications.edit 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.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
data class EditDefaultNotificationSettingState( data class EditDefaultNotificationSettingState(
val isOneToOne: Boolean, val isOneToOne: Boolean,
val mode: RoomNotificationMode?, val mode: RoomNotificationMode?,
val roomsWithUserDefinedMode: List<RoomSummary.Filled>,
val changeNotificationSettingAction: Async<Unit>,
val eventSink: (EditDefaultNotificationSettingStateEvents) -> Unit, val eventSink: (EditDefaultNotificationSettingStateEvents) -> Unit,
) )

View file

@ -20,4 +20,5 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationMode
sealed interface EditDefaultNotificationSettingStateEvents { sealed interface EditDefaultNotificationSettingStateEvents {
data class SetNotificationMode(val mode: RoomNotificationMode): EditDefaultNotificationSettingStateEvents data class SetNotificationMode(val mode: RoomNotificationMode): EditDefaultNotificationSettingStateEvents
data object ClearError: EditDefaultNotificationSettingStateEvents
} }

View file

@ -0,0 +1,58 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.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<EditDefaultNotificationSettingState> {
override val values: Sequence<EditDefaultNotificationSettingState>
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<Unit> = 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,
)
)

View file

@ -21,8 +21,21 @@ import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource 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.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.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.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -33,11 +46,12 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun EditDefaultNotificationSettingView( fun EditDefaultNotificationSettingView(
state: EditDefaultNotificationSettingState, state: EditDefaultNotificationSettingState,
openRoomNotificationSettings:(roomId: RoomId) -> Unit,
onBackPressed: () -> Unit, onBackPressed: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val title = if(state.isOneToOne) { val title = if (state.isOneToOne) {
CommonStrings.screen_notification_settings_direct_chats CommonStrings.screen_notification_settings_direct_chats
} else { } else {
CommonStrings.screen_notification_settings_group_chats CommonStrings.screen_notification_settings_group_chats
@ -51,7 +65,7 @@ fun EditDefaultNotificationSettingView(
// Only ALL_MESSAGES and MENTIONS_AND_KEYWORDS_ONLY are valid global defaults. // Only ALL_MESSAGES and MENTIONS_AND_KEYWORDS_ONLY are valid global defaults.
val validModes = listOf(RoomNotificationMode.ALL_MESSAGES, RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY) 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 CommonStrings.screen_notification_settings_edit_screen_direct_section_header
} else { } else {
CommonStrings.screen_notification_settings_edit_screen_group_section_header 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 = {},
)
}

View file

@ -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.EditDefaultNotificationSettingPresenter
import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingStateEvents 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.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.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 io.element.android.tests.testutils.consumeItemsUntilPredicate
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -32,7 +39,7 @@ class EditDefaultNotificationSettingsPresenterTests {
@Test @Test
fun `present - ensures initial state is correct`() = runTest { fun `present - ensures initial state is correct`() = runTest {
val notificationSettingsService = FakeNotificationSettingsService() val notificationSettingsService = FakeNotificationSettingsService()
val presenter = EditDefaultNotificationSettingPresenter(notificationSettingsService = notificationSettingsService, isOneToOne = false) val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.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 @Test
fun `present - edit default notification setting`() = runTest { fun `present - edit default notification setting`() = runTest {
val notificationSettingsService = FakeNotificationSettingsService() val presenter = createEditDefaultNotificationSettingPresenter()
val presenter = EditDefaultNotificationSettingPresenter(notificationSettingsService = notificationSettingsService, isOneToOne = false)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -61,4 +90,39 @@ class EditDefaultNotificationSettingsPresenterTests {
Truth.assertThat(loadedState.mode).isEqualTo(RoomNotificationMode.ALL_MESSAGES) 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
)
}
} }

View file

@ -22,6 +22,7 @@ import app.cash.turbine.test
import com.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory import com.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory
import com.google.common.truth.Truth import com.google.common.truth.Truth
import io.element.android.libraries.matrix.api.room.RoomNotificationMode 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.FakeMatrixClient
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.tests.testutils.consumeItemsUntilPredicate 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( private fun createNotificationSettingsPresenter(
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService() notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService()
) : NotificationSettingsPresenter { ) : NotificationSettingsPresenter {

View file

@ -33,9 +33,22 @@ interface RoomDetailsEntryPoint : FeatureEntryPoint {
@Parcelize @Parcelize
data class RoomMemberDetails(val roomMemberId: UserId) : InitialTarget 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<Plugin>): 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
} }

View file

@ -44,6 +44,7 @@ dependencies {
implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.mediaupload.api)
implementation(projects.libraries.featureflag.api) implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.permissions.api) implementation(projects.libraries.permissions.api)
implementation(projects.libraries.preferences.api)
api(projects.features.roomdetails.api) api(projects.features.roomdetails.api)
api(projects.libraries.usersearch.api) api(projects.libraries.usersearch.api)
api(projects.services.apperror.api) api(projects.services.apperror.api)

View file

@ -29,17 +29,30 @@ import javax.inject.Inject
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultRoomDetailsEntryPoint @Inject constructor() : RoomDetailsEntryPoint { class DefaultRoomDetailsEntryPoint @Inject constructor() : RoomDetailsEntryPoint {
override fun createNode(
parentNode: Node, override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): RoomDetailsEntryPoint.NodeBuilder {
buildContext: BuildContext, return object : RoomDetailsEntryPoint.NodeBuilder {
inputs: RoomDetailsEntryPoint.Inputs, val plugins = ArrayList<Plugin>()
plugins: List<Plugin>
): Node { override fun params(params: RoomDetailsEntryPoint.Params): RoomDetailsEntryPoint.NodeBuilder {
return parentNode.createNode<RoomDetailsFlowNode>(buildContext, plugins + inputs) plugins += params
return this
}
override fun callback(callback: RoomDetailsEntryPoint.Callback): RoomDetailsEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun build(): Node {
return parentNode.createNode<RoomDetailsFlowNode>(buildContext, plugins)
}
}
} }
} }
internal fun InitialTarget.toNavTarget() = when (this) { internal fun InitialTarget.toNavTarget() = when (this) {
is InitialTarget.RoomDetails -> NavTarget.RoomDetails is InitialTarget.RoomDetails -> NavTarget.RoomDetails
is InitialTarget.RoomMemberDetails -> NavTarget.RoomMemberDetails(roomMemberId) is InitialTarget.RoomMemberDetails -> NavTarget.RoomMemberDetails(roomMemberId)
is InitialTarget.RoomNotificationSettings -> NavTarget.RoomNotificationSettings(showUserDefinedSettingStyle = true)
} }

View file

@ -23,6 +23,7 @@ import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin 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.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted import dagger.assisted.Assisted
@ -47,7 +48,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
) : BackstackNode<RoomDetailsFlowNode.NavTarget>( ) : BackstackNode<RoomDetailsFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Inputs>().first().initialElement.toNavTarget(), initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Params>().first().initialElement.toNavTarget(),
savedStateMap = buildContext.savedStateMap, savedStateMap = buildContext.savedStateMap,
), ),
buildContext = buildContext, buildContext = buildContext,
@ -68,7 +69,13 @@ class RoomDetailsFlowNode @AssistedInject constructor(
data object InviteMembers : NavTarget data object InviteMembers : NavTarget
@Parcelize @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 @Parcelize
data class RoomMemberDetails(val roomMemberId: UserId) : NavTarget data class RoomMemberDetails(val roomMemberId: UserId) : NavTarget
@ -91,7 +98,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
} }
override fun openRoomNotificationSettings() { override fun openRoomNotificationSettings() {
backstack.push(NavTarget.RoomNotificationSettings) backstack.push(NavTarget.RoomNotificationSettings(showUserDefinedSettingStyle = false))
} }
} }
createNode<RoomDetailsNode>(buildContext, listOf(roomDetailsCallback)) createNode<RoomDetailsNode>(buildContext, listOf(roomDetailsCallback))
@ -118,8 +125,14 @@ class RoomDetailsFlowNode @AssistedInject constructor(
createNode<RoomInviteMembersNode>(buildContext) createNode<RoomInviteMembersNode>(buildContext)
} }
NavTarget.RoomNotificationSettings -> { is NavTarget.RoomNotificationSettings -> {
createNode<RoomNotificationSettingsNode>(buildContext) val input = RoomNotificationSettingsNode.RoomNotificationSettingInput(navTarget.showUserDefinedSettingStyle)
val callback = object : RoomNotificationSettingsNode.Callback {
override fun openGlobalNotificationSettings() {
plugins<RoomDetailsEntryPoint.Callback>().forEach { it.onOpenGlobalNotificationSettings() }
}
}
createNode<RoomNotificationSettingsNode>(buildContext, listOf(input, callback))
} }
is NavTarget.RoomMemberDetails -> { is NavTarget.RoomMemberDetails -> {

View file

@ -36,7 +36,11 @@ open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState>
aDmRoomDetailsState().copy(roomName = "Daniel"), aDmRoomDetailsState().copy(roomName = "Daniel"),
aDmRoomDetailsState(isDmMemberIgnored = true).copy(roomName = "Daniel"), aDmRoomDetailsState(isDmMemberIgnored = true).copy(roomName = "Daniel"),
aRoomDetailsState().copy(canInvite = true), 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 // Add other state here
) )
} }

View file

@ -19,7 +19,6 @@ package io.element.android.features.roomdetails.impl
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets 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.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun RoomDetailsView( fun RoomDetailsView(
state: RoomDetailsState, state: RoomDetailsState,

View file

@ -21,4 +21,7 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationMode
sealed interface RoomNotificationSettingsEvents { sealed interface RoomNotificationSettingsEvents {
data class RoomNotificationModeChanged(val mode: RoomNotificationMode) : RoomNotificationSettingsEvents data class RoomNotificationModeChanged(val mode: RoomNotificationMode) : RoomNotificationSettingsEvents
data class SetNotificationMode(val isDefault: Boolean): RoomNotificationSettingsEvents data class SetNotificationMode(val isDefault: Boolean): RoomNotificationSettingsEvents
data object DeleteCustomNotification: RoomNotificationSettingsEvents
data object ClearSetNotificationError: RoomNotificationSettingsEvents
data object ClearRestoreDefaultError: RoomNotificationSettingsEvents
} }

View file

@ -22,10 +22,13 @@ import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode 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.libraries.di.RoomScope
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
@ -33,10 +36,24 @@ import io.element.android.services.analytics.api.AnalyticsService
class RoomNotificationSettingsNode @AssistedInject constructor( class RoomNotificationSettingsNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val presenter: RoomNotificationSettingsPresenter, presenterFactory: RoomNotificationSettingsPresenter.Factory,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
data class RoomNotificationSettingInput(
val showUserDefinedSettingStyle: Boolean
) : NodeInputs
interface Callback : Plugin {
fun openGlobalNotificationSettings()
}
private val inputs = inputs<RoomNotificationSettingInput>()
private val callbacks = plugins<Callback>()
private fun openGlobalNotificationSettings() {
callbacks.forEach { it.openGlobalNotificationSettings() }
}
private val presenter = presenterFactory.create(inputs.showUserDefinedSettingStyle)
init { init {
lifecycle.subscribe( lifecycle.subscribe(
onResume = { onResume = {
@ -51,6 +68,7 @@ class RoomNotificationSettingsNode @AssistedInject constructor(
RoomNotificationSettingsView( RoomNotificationSettingsView(
state = state, state = state,
modifier = modifier, modifier = modifier,
onShowGlobalNotifications = this::openGlobalNotificationSettings,
onBackPressed = this::navigateUp, onBackPressed = this::navigateUp,
) )
} }

View file

@ -0,0 +1,43 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.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
)
}
}
}

View file

@ -19,90 +19,176 @@ package io.element.android.features.roomdetails.impl.notificationsettings
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable 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.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService 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.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomNotificationMode 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.CoroutineScope
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
class RoomNotificationSettingsPresenter @Inject constructor( class RoomNotificationSettingsPresenter @AssistedInject constructor(
private val room: MatrixRoom, private val room: MatrixRoom,
private val notificationSettingsService: NotificationSettingsService, private val notificationSettingsService: NotificationSettingsService,
@Assisted private val showUserDefinedSettingStyle: Boolean,
) : Presenter<RoomNotificationSettingsState> { ) : Presenter<RoomNotificationSettingsState> {
@AssistedFactory
interface Factory {
fun create(showUserDefinedSettingStyle: Boolean): RoomNotificationSettingsPresenter
}
@Composable @Composable
override fun present(): RoomNotificationSettingsState { override fun present(): RoomNotificationSettingsState {
val defaultRoomNotificationMode: MutableState<RoomNotificationMode?> = rememberSaveable { val defaultRoomNotificationMode: MutableState<RoomNotificationMode?> = rememberSaveable {
mutableStateOf(null) mutableStateOf(null)
} }
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val setNotificationSettingAction: MutableState<Async<Unit>> = remember { mutableStateOf(Async.Uninitialized) }
val restoreDefaultAction: MutableState<Async<Unit>> = remember { mutableStateOf(Async.Uninitialized) }
val roomNotificationSettings: MutableState<Async<RoomNotificationSettings>> = 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<RoomNotificationMode?> = 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<Boolean?> = remember {
mutableStateOf(null)
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
getDefaultRoomNotificationMode(defaultRoomNotificationMode) getDefaultRoomNotificationMode(defaultRoomNotificationMode)
observeNotificationSettings() fetchNotificationSettings(pendingRoomNotificationMode, roomNotificationSettings)
observeNotificationSettings(pendingRoomNotificationMode, roomNotificationSettings)
} }
val roomNotificationSettingsState by room.roomNotificationSettingsStateFlow.collectAsState()
fun handleEvents(event: RoomNotificationSettingsEvents) { fun handleEvents(event: RoomNotificationSettingsEvents) {
when (event) { when (event) {
is RoomNotificationSettingsEvents.RoomNotificationModeChanged -> { is RoomNotificationSettingsEvents.RoomNotificationModeChanged -> {
localCoroutineScope.setRoomNotificationMode(event.mode) localCoroutineScope.setRoomNotificationMode(event.mode, pendingRoomNotificationMode, pendingSetDefault, setNotificationSettingAction)
} }
is RoomNotificationSettingsEvents.SetNotificationMode -> { is RoomNotificationSettingsEvents.SetNotificationMode -> {
if (event.isDefault) { if (event.isDefault) {
localCoroutineScope.restoreDefaultRoomNotificationMode() localCoroutineScope.restoreDefaultRoomNotificationMode(restoreDefaultAction, pendingSetDefault)
} else { } else {
defaultRoomNotificationMode.value?.let { 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( return RoomNotificationSettingsState(
roomNotificationSettings = roomNotificationSettingsState.roomNotificationSettings(), showUserDefinedSettingStyle = showUserDefinedSettingStyle,
roomName = room.displayName,
roomNotificationSettings = roomNotificationSettings.value,
pendingRoomNotificationMode = pendingRoomNotificationMode.value,
pendingSetDefault = pendingSetDefault.value,
defaultRoomNotificationMode = defaultRoomNotificationMode.value, defaultRoomNotificationMode = defaultRoomNotificationMode.value,
setNotificationSettingAction = setNotificationSettingAction.value,
restoreDefaultAction = restoreDefaultAction.value,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
private fun CoroutineScope.observeNotificationSettings() { private fun CoroutineScope.observeNotificationSettings(
pendingModeState: MutableState<RoomNotificationMode?>,
roomNotificationSettings: MutableState<Async<RoomNotificationSettings>>
) {
notificationSettingsService.notificationSettingsChangeFlow notificationSettingsService.notificationSettingsChangeFlow
.debounce(0.5.seconds) .debounce(0.5.seconds)
.onEach { .onEach {
room.updateRoomNotificationSettings() fetchNotificationSettings(pendingModeState, roomNotificationSettings)
} }
.launchIn(this) .launchIn(this)
} }
private fun CoroutineScope.getDefaultRoomNotificationMode(defaultRoomNotificationMode: MutableState<RoomNotificationMode?>) = launch { private fun CoroutineScope.fetchNotificationSettings(
pendingModeState: MutableState<RoomNotificationMode?>,
roomNotificationSettings: MutableState<Async<RoomNotificationSettings>>
) = launch {
suspend {
pendingModeState.value = null
notificationSettingsService.getRoomNotificationSettings(room.roomId, room.isEncrypted, room.isOneToOne).getOrThrow()
}.runCatchingUpdatingState(roomNotificationSettings)
}
private fun CoroutineScope.getDefaultRoomNotificationMode(
defaultRoomNotificationMode: MutableState<RoomNotificationMode?>
) = launch {
defaultRoomNotificationMode.value = notificationSettingsService.getDefaultRoomNotificationMode( defaultRoomNotificationMode.value = notificationSettingsService.getDefaultRoomNotificationMode(
room.isEncrypted, room.isEncrypted,
room.isOneToOne room.isOneToOne
).getOrThrow() ).getOrThrow()
} }
private fun CoroutineScope.setRoomNotificationMode(mode: RoomNotificationMode) = launch { private fun CoroutineScope.setRoomNotificationMode(
notificationSettingsService.setRoomNotificationMode(room.roomId, mode) mode: RoomNotificationMode,
pendingModeState: MutableState<RoomNotificationMode?>,
pendingDefaultState: MutableState<Boolean?>,
action: MutableState<Async<Unit>>
) = 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 { private fun CoroutineScope.restoreDefaultRoomNotificationMode(
notificationSettingsService.restoreDefaultRoomNotificationMode(room.roomId) action: MutableState<Async<Unit>>,
pendingDefaultState: MutableState<Boolean?>
) = launch {
suspend {
pendingDefaultState.value = true
val result = notificationSettingsService.restoreDefaultRoomNotificationMode(room.roomId)
if (result.isFailure) {
pendingDefaultState.value = null
}
result.getOrThrow()
}.runCatchingUpdatingState(action)
} }
} }

View file

@ -16,11 +16,26 @@
package io.element.android.features.roomdetails.impl.notificationsettings 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.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
data class RoomNotificationSettingsState( data class RoomNotificationSettingsState(
val roomNotificationSettings: RoomNotificationSettings?, val showUserDefinedSettingStyle: Boolean,
val roomName: String,
val roomNotificationSettings: Async<RoomNotificationSettings>,
val pendingRoomNotificationMode: RoomNotificationMode?,
val pendingSetDefault: Boolean?,
val defaultRoomNotificationMode: RoomNotificationMode?, val defaultRoomNotificationMode: RoomNotificationMode?,
val setNotificationSettingAction: Async<Unit>,
val restoreDefaultAction: Async<Unit>,
val eventSink: (RoomNotificationSettingsEvents) -> Unit val eventSink: (RoomNotificationSettingsEvents) -> Unit
) )
val RoomNotificationSettingsState.displayNotificationMode: RoomNotificationMode? get() {
return pendingRoomNotificationMode ?: roomNotificationSettings.dataOrNull()?.mode
}
val RoomNotificationSettingsState.displayIsDefault: Boolean? get() {
return pendingSetDefault ?: roomNotificationSettings.dataOrNull()?.isDefault
}

View file

@ -17,18 +17,38 @@
package io.element.android.features.roomdetails.impl.notificationsettings package io.element.android.features.roomdetails.impl.notificationsettings
import androidx.compose.ui.tooling.preview.PreviewParameterProvider 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.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
internal class RoomNotificationSettingsStateProvider : PreviewParameterProvider<RoomNotificationSettingsState> { internal class RoomNotificationSettingsStateProvider : PreviewParameterProvider<RoomNotificationSettingsState> {
override val values: Sequence<RoomNotificationSettingsState> override val values: Sequence<RoomNotificationSettingsState>
get() = sequenceOf( get() = sequenceOf(
RoomNotificationSettingsState( aRoomNotificationSettingsState(),
RoomNotificationSettings( aRoomNotificationSettingsState(isDefault = false),
mode = RoomNotificationMode.MUTE, aRoomNotificationSettingsState(setNotificationSettingAction = Async.Loading(Unit)),
isDefault = true), aRoomNotificationSettingsState(setNotificationSettingAction = Async.Failure(Throwable("error"))),
RoomNotificationMode.ALL_MESSAGES, aRoomNotificationSettingsState(restoreDefaultAction = Async.Loading(Unit)),
eventSink = { }, aRoomNotificationSettingsState(restoreDefaultAction = Async.Failure(Throwable("error"))),
),
) )
private fun aRoomNotificationSettingsState(
isDefault: Boolean = true,
setNotificationSettingAction: Async<Unit> = Async.Uninitialized,
restoreDefaultAction: Async<Unit> = 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 = { },
)
}
} }

View file

@ -18,25 +18,29 @@ package io.element.android.features.roomdetails.impl.notificationsettings
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding 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.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.roomdetails.impl.R 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.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.button.BackButton
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory 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.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.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.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.Scaffold 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.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.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun RoomNotificationSettingsView( fun RoomNotificationSettingsView(
state: RoomNotificationSettingsState, state: RoomNotificationSettingsState,
modifier: Modifier = Modifier, 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 = {}, onBackPressed: () -> Unit = {},
) { ) {
Scaffold( Scaffold(
@ -67,41 +95,84 @@ fun RoomNotificationSettingsView(
.consumeWindowInsets(padding), .consumeWindowInsets(padding),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
val subtitle = when (state.defaultRoomNotificationMode) { val roomNotificationSettings = state.roomNotificationSettings.dataOrNull()
RoomNotificationMode.ALL_MESSAGES -> stringResource(id = R.string.screen_room_notification_settings_mode_all_messages) PreferenceSwitch(
RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> stringResource(id = R.string.screen_room_notification_settings_mode_mentions_and_keywords) isChecked = !state.displayIsDefault.orTrue(),
RoomNotificationMode.MUTE -> stringResource(id = CommonStrings.common_mute) onCheckedChange = {
null -> "" 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),
PreferenceCategory(title = stringResource(id = R.string.screen_room_notification_settings_custom_settings_title)) { enabled = roomNotificationSettings != null
PreferenceSwitch( )
isChecked = state.roomNotificationSettings?.isDefault.orTrue(), if (state.displayIsDefault.orTrue()) {
onCheckedChange = { PreferenceCategory(title = stringResource(id = R.string.screen_room_notification_settings_default_setting_title)) {
state.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(it)) val text = buildAnnotatedStringWithStyledPart(
}, R.string.screen_room_notification_settings_default_setting_footnote,
title = "Match default setting", R.string.screen_room_notification_settings_default_setting_footnote_content_link,
subtitle = subtitle, color = Color.Unspecified,
enabled = state.roomNotificationSettings != null underline = false,
) bold = true,
)
PreferenceText( ClickableText(
title = stringResource(id = R.string.screen_room_notification_settings_allow_custom), text = text,
subtitle = stringResource(id = R.string.screen_room_notification_settings_allow_custom_footnote), onClick = {
enabled = state.roomNotificationSettings != null && !state.roomNotificationSettings.isDefault, onShowGlobalNotifications()
) },
modifier = Modifier
if (state.roomNotificationSettings != null) { .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( RoomNotificationSettingsOptions(
selected = state.roomNotificationSettings.mode, selected = state.displayNotificationMode,
enabled = !state.roomNotificationSettings.isDefault, enabled = !state.displayIsDefault.orTrue(),
onOptionSelected = { onOptionSelected = {
state.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(it.mode)) 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 @PreviewsDayNight
@Composable @Composable
internal fun RoomNotificationSettingsPreview( internal fun RoomNotificationSettingsPreview(

View file

@ -0,0 +1,31 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
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) },
)
}

View file

@ -0,0 +1,43 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.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<RoomNotificationSettingsState> {
override val values: Sequence<RoomNotificationSettingsState>
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 = { },
),
)
}

View file

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

View file

@ -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.RoomNotificationSettingsEvents
import io.element.android.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsPresenter import io.element.android.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsPresenter
import io.element.android.libraries.matrix.api.room.RoomNotificationMode 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 io.element.android.tests.testutils.consumeItemsUntilPredicate
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -31,12 +34,12 @@ import org.junit.Test
class RoomNotificationSettingsPresenterTests { class RoomNotificationSettingsPresenterTests {
@Test @Test
fun `present - initial state is created from room info`() = runTest { fun `present - initial state is created from room info`() = runTest {
val presenter = aNotificationPresenter val presenter = createRoomNotificationSettingsPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
Truth.assertThat(initialState.roomNotificationSettings).isNull() Truth.assertThat(initialState.roomNotificationSettings.dataOrNull()).isNull()
Truth.assertThat(initialState.defaultRoomNotificationMode).isNull() Truth.assertThat(initialState.defaultRoomNotificationMode).isNull()
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
@ -44,21 +47,80 @@ class RoomNotificationSettingsPresenterTests {
@Test @Test
fun `present - notification mode changed`() = runTest { fun `present - notification mode changed`() = runTest {
val presenter = aNotificationPresenter val presenter = createRoomNotificationSettingsPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
awaitItem().eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)) awaitItem().eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY))
val updatedState = consumeItemsUntilPredicate { val updatedState = consumeItemsUntilPredicate {
it.roomNotificationSettings?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY it.roomNotificationSettings.dataOrNull()?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
}.last() }.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 @Test
fun `present - notification settings restore default`() = runTest { fun `present - notification settings restore default`() = runTest {
val presenter = aNotificationPresenter val presenter = createRoomNotificationSettingsPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -66,17 +128,45 @@ class RoomNotificationSettingsPresenterTests {
initialState.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)) initialState.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY))
initialState.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(true)) initialState.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(true))
val defaultState = consumeItemsUntilPredicate { val defaultState = consumeItemsUntilPredicate {
it.roomNotificationSettings?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY it.roomNotificationSettings.dataOrNull()?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
}.last() }.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() { @Test
val room = aMatrixRoom() 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( return RoomNotificationSettingsPresenter(
room = room, room = room,
notificationSettingsService = room.notificationSettingsService notificationSettingsService = notificationSettingsService,
showUserDefinedSettingStyle = false,
) )
} }
} }

View file

@ -75,6 +75,7 @@ google_firebase_bom = "com.google.firebase:firebase-bom:32.4.0"
# AndroidX # AndroidX
androidx_core = { module = "androidx.core:core", version.ref = "core" } androidx_core = { module = "androidx.core:core", version.ref = "core" }
androidx_corektx = { module = "androidx.core:core-ktx", 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_preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
androidx_datastore_datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } androidx_datastore_datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
androidx_exifinterface = "androidx.exifinterface:exifinterface:1.3.6" 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_orchestrator = "androidx.test:orchestrator:1.4.2"
test_turbine = "app.cash.turbine:turbine:1.0.0" test_turbine = "app.cash.turbine:turbine:1.0.0"
test_truth = "com.google.truth:truth:1.1.5" 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_robolectric = "org.robolectric:robolectric:4.10.3"
test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = "appyx" } 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" } appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" } molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" }
timber = "com.jakewharton.timber:timber:5.0.1" 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 = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" }
matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", 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" } 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 = "org.maplibre.gl:android-sdk:10.2.0"
maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:2.0.1" maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:2.0.1"
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9: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 # Analytics
posthog = "com.posthog.android:posthog:2.0.3" posthog = "com.posthog.android:posthog:2.0.3"

View file

@ -0,0 +1,35 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.core.hash
import java.security.MessageDigest
import java.util.Locale
/**
* Compute a Hash of a String, using md5 algorithm.
*/
fun String.md5() = try {
val digest = MessageDigest.getInstance("md5")
val locale = Locale.ROOT
digest.update(toByteArray())
digest.digest()
.joinToString("") { String.format(locale, "%02X", it) }
.lowercase(locale)
} catch (exc: Exception) {
// Should not happen, but just in case
hashCode().toString()
}

View file

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

View file

@ -0,0 +1,28 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.encryption
enum class BackupState {
UNKNOWN,
CREATING,
ENABLING,
RESUMING,
ENABLED,
DOWNLOADING,
DISABLING,
DISABLED;
}

View file

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

View file

@ -28,6 +28,7 @@ data class TracingFilterConfiguration(
Target.MATRIX_SDK_HTTP_CLIENT to LogLevel.DEBUG, Target.MATRIX_SDK_HTTP_CLIENT to LogLevel.DEBUG,
Target.MATRIX_SDK_SLIDING_SYNC to LogLevel.TRACE, Target.MATRIX_SDK_SLIDING_SYNC to LogLevel.TRACE,
Target.MATRIX_SDK_BASE_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 { fun getLogLevel(target: Target): LogLevel {

View file

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

View file

@ -0,0 +1,35 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.impl.encryption
import io.element.android.libraries.matrix.api.encryption.BackupState
import org.matrix.rustcomponents.sdk.BackupState as RustBackupState
class BackupStateMapper {
fun map(backupState: RustBackupState): BackupState {
return when (backupState) {
RustBackupState.UNKNOWN -> BackupState.UNKNOWN
RustBackupState.CREATING -> BackupState.CREATING
RustBackupState.ENABLING -> BackupState.ENABLING
RustBackupState.RESUMING -> BackupState.RESUMING
RustBackupState.ENABLED -> BackupState.ENABLED
RustBackupState.DOWNLOADING -> BackupState.DOWNLOADING
RustBackupState.DISABLING -> BackupState.DISABLING
RustBackupState.DISABLED -> BackupState.DISABLED
}
}
}

View file

@ -28,6 +28,8 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.NotificationSettings import org.matrix.rustcomponents.sdk.NotificationSettings
import org.matrix.rustcomponents.sdk.NotificationSettingsDelegate import org.matrix.rustcomponents.sdk.NotificationSettingsDelegate
import org.matrix.rustcomponents.sdk.NotificationSettingsException
import timber.log.Timber
class RustNotificationSettingsService( class RustNotificationSettingsService(
private val notificationSettings: NotificationSettings, private val notificationSettings: NotificationSettings,
@ -63,7 +65,13 @@ class RustNotificationSettingsService(
isOneToOne: Boolean isOneToOne: Boolean
): Result<Unit> = withContext(dispatchers.io) { ): Result<Unit> = withContext(dispatchers.io) {
runCatching { 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) notificationSettings.setCallEnabled(enabled)
} }
} }
override suspend fun getRoomsWithUserDefinedRules(): Result<List<String>> =
runCatching {
notificationSettings.getRoomsWithUserDefinedRules(enabled = true)
}
} }

View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more