Merge branch 'develop' into feature/fga/pin_settings
This commit is contained in:
commit
5d98f645d2
376 changed files with 6593 additions and 384 deletions
|
|
@ -187,7 +187,9 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object Settings : NavTarget
|
||||
data class Settings(
|
||||
val initialElement: PreferencesEntryPoint.InitialTarget = PreferencesEntryPoint.InitialTarget.Root
|
||||
) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object CreateRoom : NavTarget
|
||||
|
|
@ -219,7 +221,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
}
|
||||
|
||||
override fun onSettingsClicked() {
|
||||
backstack.push(NavTarget.Settings)
|
||||
backstack.push(NavTarget.Settings())
|
||||
}
|
||||
|
||||
override fun onCreateRoomClicked() {
|
||||
|
|
@ -252,11 +254,15 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
override fun onForwardedToSingleRoom(roomId: RoomId) {
|
||||
coroutineScope.launch { attachRoom(roomId) }
|
||||
}
|
||||
|
||||
override fun onOpenGlobalNotificationSettings() {
|
||||
backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.NotificationSettings))
|
||||
}
|
||||
}
|
||||
val inputs = RoomFlowNode.Inputs(roomId = navTarget.roomId, initialElement = navTarget.initialElement)
|
||||
createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs, callback))
|
||||
}
|
||||
NavTarget.Settings -> {
|
||||
is NavTarget.Settings -> {
|
||||
val callback = object : PreferencesEntryPoint.Callback {
|
||||
override fun onOpenBugReport() {
|
||||
plugins<Callback>().forEach { it.onOpenBugReport() }
|
||||
|
|
@ -265,8 +271,14 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
override fun onVerifyClicked() {
|
||||
backstack.push(NavTarget.VerifySession)
|
||||
}
|
||||
|
||||
override fun onOpenRoomNotificationSettings(roomId: RoomId) {
|
||||
backstack.push(NavTarget.Room(roomId, initialElement = RoomLoadedFlowNode.NavTarget.RoomNotificationSettings))
|
||||
}
|
||||
}
|
||||
preferencesEntryPoint.nodeBuilder(this, buildContext)
|
||||
val inputs = PreferencesEntryPoint.Params(navTarget.initialElement)
|
||||
return preferencesEntryPoint.nodeBuilder(this, buildContext)
|
||||
.params(inputs)
|
||||
.callback(callback)
|
||||
.build()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ class RoomLoadedFlowNode @AssistedInject constructor(
|
|||
|
||||
interface Callback : Plugin {
|
||||
fun onForwardedToSingleRoom(roomId: RoomId)
|
||||
fun onOpenGlobalNotificationSettings()
|
||||
}
|
||||
|
||||
data class Inputs(
|
||||
|
|
@ -128,6 +129,18 @@ class RoomLoadedFlowNode @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun createRoomDetailsNode(buildContext: BuildContext, initialTarget: RoomDetailsEntryPoint.InitialTarget): Node {
|
||||
val callback = object : RoomDetailsEntryPoint.Callback {
|
||||
override fun onOpenGlobalNotificationSettings() {
|
||||
callbacks.forEach { it.onOpenGlobalNotificationSettings() }
|
||||
}
|
||||
}
|
||||
return roomDetailsEntryPoint.nodeBuilder(this, buildContext)
|
||||
.params(RoomDetailsEntryPoint.Params(initialTarget))
|
||||
.callback(callback)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
NavTarget.Messages -> {
|
||||
|
|
@ -147,12 +160,13 @@ class RoomLoadedFlowNode @AssistedInject constructor(
|
|||
messagesEntryPoint.createNode(this, buildContext, callback)
|
||||
}
|
||||
NavTarget.RoomDetails -> {
|
||||
val inputs = RoomDetailsEntryPoint.Inputs(RoomDetailsEntryPoint.InitialTarget.RoomDetails)
|
||||
roomDetailsEntryPoint.createNode(this, buildContext, inputs, emptyList())
|
||||
createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomDetails)
|
||||
}
|
||||
is NavTarget.RoomMemberDetails -> {
|
||||
val inputs = RoomDetailsEntryPoint.Inputs(RoomDetailsEntryPoint.InitialTarget.RoomMemberDetails(navTarget.userId))
|
||||
roomDetailsEntryPoint.createNode(this, buildContext, inputs, emptyList())
|
||||
createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomMemberDetails(navTarget.userId))
|
||||
}
|
||||
NavTarget.RoomNotificationSettings -> {
|
||||
createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomNotificationSettings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -166,6 +180,9 @@ class RoomLoadedFlowNode @AssistedInject constructor(
|
|||
|
||||
@Parcelize
|
||||
data class RoomMemberDetails(val userId: UserId) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object RoomNotificationSettings : NavTarget
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -71,14 +71,22 @@ class RoomFlowNodeTest {
|
|||
|
||||
var nodeId: String? = null
|
||||
|
||||
override fun createNode(
|
||||
parentNode: Node,
|
||||
buildContext: BuildContext,
|
||||
inputs: RoomDetailsEntryPoint.Inputs,
|
||||
plugins: List<Plugin>
|
||||
): Node {
|
||||
return node(buildContext) {}.also {
|
||||
nodeId = it.id
|
||||
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): RoomDetailsEntryPoint.NodeBuilder {
|
||||
return object : RoomDetailsEntryPoint.NodeBuilder {
|
||||
|
||||
override fun params(params: RoomDetailsEntryPoint.Params): RoomDetailsEntryPoint.NodeBuilder {
|
||||
return this
|
||||
}
|
||||
|
||||
override fun callback(callback: RoomDetailsEntryPoint.Callback): RoomDetailsEntryPoint.NodeBuilder {
|
||||
return this
|
||||
}
|
||||
|
||||
override fun build(): Node {
|
||||
return node(buildContext) {}.also {
|
||||
nodeId = it.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -198,6 +198,8 @@ koverMerged {
|
|||
// We do not cover Nodes (normally covered by maestro, but code coverage is not computed with maestro)
|
||||
"*Node",
|
||||
"*Node$*",
|
||||
// Exclude `:libraries:matrix:impl` module, it contains only wrappers to access the Rust Matrix SDK api, so it is not really relevant to unit test it: there is no logic to test.
|
||||
"io.element.android.libraries.matrix.impl.*",
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
1
changelog.d/1596.feature
Normal file
1
changelog.d/1596.feature
Normal file
|
|
@ -0,0 +1 @@
|
|||
Record and send voice messages
|
||||
1
changelog.d/2084.feature
Normal file
1
changelog.d/2084.feature
Normal file
|
|
@ -0,0 +1 @@
|
|||
Receive and play a voice message
|
||||
|
|
@ -51,6 +51,8 @@ dependencies {
|
|||
implementation(projects.libraries.mediaupload.api)
|
||||
implementation(projects.libraries.permissions.api)
|
||||
implementation(projects.libraries.preferences.api)
|
||||
implementation(projects.libraries.voicerecorder.api)
|
||||
implementation(projects.libraries.uiUtils)
|
||||
implementation(projects.features.networkmonitor.api)
|
||||
implementation(projects.services.analytics.api)
|
||||
implementation(libs.coil.compose)
|
||||
|
|
@ -63,6 +65,7 @@ dependencies {
|
|||
implementation(libs.vanniktech.blurhash)
|
||||
implementation(libs.telephoto.zoomableimage)
|
||||
implementation(libs.matrix.emojibase.bindings)
|
||||
implementation(libs.audiowaveform)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
|
|
@ -80,6 +83,7 @@ dependencies {
|
|||
testImplementation(projects.libraries.permissions.test)
|
||||
testImplementation(projects.libraries.preferences.test)
|
||||
testImplementation(projects.libraries.textcomposer.test)
|
||||
testImplementation(projects.libraries.voicerecorder.test)
|
||||
testImplementation(libs.test.mockk)
|
||||
|
||||
ksp(libs.showkase.processor)
|
||||
|
|
|
|||
20
features/messages/impl/src/main/AndroidManifest.xml
Normal file
20
features/messages/impl/src/main/AndroidManifest.xml
Normal 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>
|
||||
|
|
@ -28,9 +28,10 @@ import dagger.assisted.Assisted
|
|||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.mediaplayer.MediaPlayer
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
|
|
@ -49,6 +50,7 @@ class MessagesNode @AssistedInject constructor(
|
|||
private val analyticsService: AnalyticsService,
|
||||
private val presenterFactory: MessagesPresenter.Factory,
|
||||
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
|
||||
private val mediaPlayer: MediaPlayer,
|
||||
) : Node(buildContext, plugins = plugins), MessagesNavigator {
|
||||
|
||||
private val presenter = presenterFactory.create(this)
|
||||
|
|
@ -71,6 +73,9 @@ class MessagesNode @AssistedInject constructor(
|
|||
lifecycle.subscribe(
|
||||
onCreate = {
|
||||
analyticsService.capture(room.toAnalyticsViewRoom())
|
||||
},
|
||||
onDestroy = {
|
||||
mediaPlayer.close()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,8 +54,9 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||
import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter
|
||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerPresenter
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
|
||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||
import io.element.android.features.preferences.api.store.PreferencesStore
|
||||
|
|
@ -63,6 +64,7 @@ import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
|
|||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
|
|
@ -101,6 +103,7 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
private val preferencesStore: PreferencesStore,
|
||||
private val featureFlagsService: FeatureFlagService,
|
||||
@Assisted private val navigator: MessagesNavigator,
|
||||
private val buildMeta: BuildMeta,
|
||||
) : Presenter<MessagesState> {
|
||||
|
||||
@AssistedFactory
|
||||
|
|
@ -203,6 +206,7 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
enableTextFormatting = enableTextFormatting,
|
||||
enableVoiceMessages = enableVoiceMessages,
|
||||
enableInRoomCalls = enableInRoomCalls,
|
||||
appName = buildMeta.applicationName,
|
||||
eventSink = { handleEvents(it) }
|
||||
)
|
||||
}
|
||||
|
|
@ -328,6 +332,7 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
type = AttachmentThumbnailType.Location,
|
||||
)
|
||||
is TimelineItemPollContent, // TODO Polls: handle reply to
|
||||
is TimelineItemVoiceContent, // TODO Voice messages: handle reply to
|
||||
is TimelineItemTextBasedContent,
|
||||
is TimelineItemRedactedContent,
|
||||
is TimelineItemStateContent,
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import io.element.android.features.messages.impl.timeline.TimelineState
|
|||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
|
||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
|
||||
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
|
||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerState
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
|
|
@ -50,5 +50,6 @@ data class MessagesState(
|
|||
val enableTextFormatting: Boolean,
|
||||
val enableVoiceMessages: Boolean,
|
||||
val enableInRoomCalls: Boolean,
|
||||
val appName: String,
|
||||
val eventSink: (MessagesEvents) -> Unit
|
||||
)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ package io.element.android.features.messages.impl
|
|||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.messages.impl.actionlist.anActionListState
|
||||
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
|
||||
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemList
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineState
|
||||
|
|
@ -25,13 +26,14 @@ import io.element.android.features.messages.impl.timeline.components.customreact
|
|||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
|
||||
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.voicemessages.aVoiceMessageComposerState
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
|
||||
open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
|
||||
|
|
@ -47,6 +49,20 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
|
|||
roomAvatar = Async.Uninitialized,
|
||||
),
|
||||
aMessagesState().copy(composerState = aMessageComposerState().copy(showTextFormatting = true)),
|
||||
aMessagesState().copy(
|
||||
enableVoiceMessages = true,
|
||||
voiceMessageComposerState = aVoiceMessageComposerState(showPermissionRationaleDialog = true),
|
||||
),
|
||||
aMessagesState().copy(
|
||||
composerState = aMessageComposerState().copy(
|
||||
attachmentsState = AttachmentsState.Sending.Processing(persistentListOf())
|
||||
),
|
||||
),
|
||||
aMessagesState().copy(
|
||||
composerState = aMessageComposerState().copy(
|
||||
attachmentsState = AttachmentsState.Sending.Uploading(0.33f)
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -86,5 +102,6 @@ fun aMessagesState() = MessagesState(
|
|||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
enableInRoomCalls = true,
|
||||
appName = "Element",
|
||||
eventSink = {}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum
|
|||
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuEvents
|
||||
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMessageMenu
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerEvents
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog
|
||||
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
|
||||
import io.element.android.libraries.androidutils.ui.hideKeyboard
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule
|
||||
|
|
@ -83,6 +85,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
|
|||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
import io.element.android.libraries.designsystem.utils.LogCompositions
|
||||
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
|
@ -107,6 +110,10 @@ fun MessagesView(
|
|||
) {
|
||||
LogCompositions(tag = "MessagesScreen", msg = "Root")
|
||||
|
||||
OnLifecycleEvent { _, event ->
|
||||
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.LifecycleEvent(event))
|
||||
}
|
||||
|
||||
AttachmentStateView(
|
||||
state = state.composerState.attachmentsState,
|
||||
onPreviewAttachments = onPreviewAttachments,
|
||||
|
|
@ -306,6 +313,18 @@ private fun MessagesViewContent(
|
|||
enableTextFormatting = state.enableTextFormatting,
|
||||
)
|
||||
|
||||
if (state.enableVoiceMessages && state.voiceMessageComposerState.showPermissionRationaleDialog) {
|
||||
VoiceMessagePermissionRationaleDialog(
|
||||
onContinue = {
|
||||
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.AcceptPermissionRationale)
|
||||
},
|
||||
onDismiss = {
|
||||
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.DismissPermissionsRationale)
|
||||
},
|
||||
appName = state.appName
|
||||
)
|
||||
}
|
||||
|
||||
ExpandableBottomSheetScaffold(
|
||||
sheetDragHandle = if (state.composerState.showTextFormatting) {
|
||||
@Composable { BottomSheetDragHandle() }
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
|||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.canBeCopied
|
||||
import io.element.android.features.messages.impl.timeline.model.event.canReact
|
||||
import io.element.android.features.preferences.api.store.PreferencesStore
|
||||
|
|
@ -131,6 +132,23 @@ class ActionListPresenter @Inject constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
is TimelineItemVoiceContent -> {
|
||||
buildList {
|
||||
if (timelineItem.isRemote) {
|
||||
add(TimelineItemAction.Reply)
|
||||
add(TimelineItemAction.Forward)
|
||||
}
|
||||
if (isDeveloperModeEnabled) {
|
||||
add(TimelineItemAction.ViewSource)
|
||||
}
|
||||
if (!timelineItem.isMine) {
|
||||
add(TimelineItemAction.ReportContent)
|
||||
}
|
||||
if (timelineItem.isMine || userCanRedact) {
|
||||
add(TimelineItemAction.Redact)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> buildList<TimelineItemAction> {
|
||||
if (timelineItem.isRemote) {
|
||||
// Can only reply or forward messages already uploaded to the server
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||
import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatterImpl
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
|
|
@ -237,6 +238,7 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
|
|||
|
||||
when (event.content) {
|
||||
is TimelineItemPollContent, // TODO Polls: handle summary
|
||||
is TimelineItemVoiceContent, // TODO Voice messages: handle reply summary
|
||||
is TimelineItemTextBasedContent,
|
||||
is TimelineItemStateContent,
|
||||
is TimelineItemEncryptedContent,
|
||||
|
|
@ -348,7 +350,7 @@ private fun EmojiReactionsRow(
|
|||
) {
|
||||
// TODO use most recently used emojis here when available from the Rust SDK
|
||||
val defaultEmojis = sequenceOf(
|
||||
"👍", "👎", "🔥", "❤️", "👏"
|
||||
"👍️", "👎️", "🔥", "❤️", "👏"
|
||||
)
|
||||
for (emoji in defaultEmojis) {
|
||||
val isHighlighted = highlightedEmojis.contains(emoji)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -24,10 +24,10 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerEvents
|
||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerState
|
||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerStateProvider
|
||||
import io.element.android.features.messages.impl.voicemessages.aVoiceMessageComposerState
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerEvents
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerStateProvider
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.textcomposer.model.Message
|
||||
|
|
@ -71,10 +71,18 @@ internal fun MessageComposerView(
|
|||
}
|
||||
}
|
||||
|
||||
fun onVoiceRecordButtonEvent(press: PressEvent) {
|
||||
val onVoiceRecordButtonEvent = { press: PressEvent ->
|
||||
voiceMessageState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(press))
|
||||
}
|
||||
|
||||
val onSendVoiceMessage = {
|
||||
voiceMessageState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||
}
|
||||
|
||||
val onDeleteVoiceMessage = {
|
||||
voiceMessageState.eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage)
|
||||
}
|
||||
|
||||
TextComposer(
|
||||
modifier = modifier,
|
||||
state = state.richTextEditorState,
|
||||
|
|
@ -89,7 +97,9 @@ internal fun MessageComposerView(
|
|||
onDismissTextFormatting = ::onDismissTextFormatting,
|
||||
enableTextFormatting = enableTextFormatting,
|
||||
enableVoiceMessages = enableVoiceMessages,
|
||||
onVoiceRecordButtonEvent = ::onVoiceRecordButtonEvent,
|
||||
onVoiceRecordButtonEvent = onVoiceRecordButtonEvent,
|
||||
onSendVoiceMessage = onSendVoiceMessage,
|
||||
onDeleteVoiceMessage = onDeleteVoiceMessage,
|
||||
onError = ::onError,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ fun aTimelineItemReactions(
|
|||
count: Int = 1,
|
||||
isHighlighted: Boolean = false,
|
||||
): TimelineItemReactions {
|
||||
val emojis = arrayOf("👍", "😀️", "😁️", "😆️", "😅️", "🤣️", "🥰️", "😇️", "😊️", "😉️", "🙃️", "🙂️", "😍️", "🤗️", "🤭️")
|
||||
val emojis = arrayOf("👍️", "😀️", "😁️", "😆️", "😅️", "🤣️", "🥰️", "😇️", "😊️", "😉️", "🙃️", "🙂️", "😍️", "🤗️", "🤭️")
|
||||
return TimelineItemReactions(
|
||||
reactions = buildList {
|
||||
repeat(count) { index ->
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -17,31 +17,22 @@
|
|||
package io.element.android.features.messages.impl.timeline.components.customreaction
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.material3.SecondaryTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -52,8 +43,6 @@ import io.element.android.emojibasebindings.EmojibaseStore
|
|||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -101,31 +90,12 @@ fun EmojiPicker(
|
|||
contentPadding = PaddingValues(vertical = 10.dp, horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
|
||||
items(emojis, key = { it.unicode }) { item ->
|
||||
val backgroundColor = if (selectedEmojis.contains(item.unicode)) {
|
||||
ElementTheme.colors.bgActionPrimaryRest
|
||||
} else {
|
||||
Color.Transparent
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.background(backgroundColor, CircleShape)
|
||||
.clickable(
|
||||
enabled = true,
|
||||
onClick = { onEmojiSelected(item) },
|
||||
indication = rememberRipple(bounded = false, radius = 20.dp),
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = item.unicode,
|
||||
style = ElementTheme.typography.fontHeadingSmRegular,
|
||||
)
|
||||
}
|
||||
EmojiItem(
|
||||
item = item,
|
||||
isSelected = selectedEmojis.contains(item.unicode),
|
||||
onEmojiSelected = onEmojiSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.di.rememberPresenter
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||
|
|
@ -32,6 +34,9 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageState
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
|
||||
@Composable
|
||||
fun TimelineItemEventContentView(
|
||||
|
|
@ -44,6 +49,7 @@ fun TimelineItemEventContentView(
|
|||
eventSink: (TimelineEvents) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val presenterFactories = LocalTimelineItemPresenterFactories.current
|
||||
when (content) {
|
||||
is TimelineItemEncryptedContent -> TimelineItemEncryptedView(
|
||||
content = content,
|
||||
|
|
@ -100,5 +106,14 @@ fun TimelineItemEventContentView(
|
|||
eventSink = eventSink,
|
||||
modifier = modifier,
|
||||
)
|
||||
is TimelineItemVoiceContent -> {
|
||||
val presenter: Presenter<VoiceMessageState> = presenterFactories.rememberPresenter(content)
|
||||
TimelineItemVoiceView(
|
||||
state = presenter.present(),
|
||||
content = content,
|
||||
extraPadding = extraPadding,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -52,7 +52,7 @@ class TimelineItemContentFactory @Inject constructor(
|
|||
is FailedToParseStateContent -> failedToParseStateFactory.create(itemContent)
|
||||
is MessageContent -> {
|
||||
val senderDisplayName = (eventTimelineItem.senderProfile as? ProfileTimelineDetails.Ready)?.displayName ?: eventTimelineItem.sender.value
|
||||
messageFactory.create(itemContent, senderDisplayName)
|
||||
messageFactory.create(itemContent, senderDisplayName, eventTimelineItem.eventId)
|
||||
}
|
||||
is ProfileChangeContent -> profileChangeFactory.create(eventTimelineItem)
|
||||
is RedactedContent -> redactedMessageFactory.create(itemContent)
|
||||
|
|
|
|||
|
|
@ -26,10 +26,14 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemNoticeContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||
import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractor
|
||||
import io.element.android.features.messages.impl.timeline.util.toHtmlDocument
|
||||
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
|
||||
|
|
@ -41,15 +45,18 @@ import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageT
|
|||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import java.time.Duration
|
||||
import javax.inject.Inject
|
||||
|
||||
class TimelineItemContentMessageFactory @Inject constructor(
|
||||
private val fileSizeFormatter: FileSizeFormatter,
|
||||
private val fileExtensionExtractor: FileExtensionExtractor,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
) {
|
||||
|
||||
fun create(content: MessageContent, senderDisplayName: String): TimelineItemEventContent {
|
||||
suspend fun create(content: MessageContent, senderDisplayName: String, eventId: EventId?): TimelineItemEventContent {
|
||||
return when (val messageType = content.type) {
|
||||
is EmoteMessageType -> TimelineItemEmoteContent(
|
||||
body = "* $senderDisplayName ${messageType.body}",
|
||||
|
|
@ -103,14 +110,24 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
|||
fileExtension = fileExtensionExtractor.extractFromName(messageType.body)
|
||||
)
|
||||
}
|
||||
is AudioMessageType -> TimelineItemAudioContent(
|
||||
body = messageType.body,
|
||||
mediaSource = messageType.source,
|
||||
duration = messageType.info?.duration ?: Duration.ZERO,
|
||||
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
|
||||
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
|
||||
fileExtension = fileExtensionExtractor.extractFromName(messageType.body),
|
||||
)
|
||||
is AudioMessageType -> when {
|
||||
featureFlagService.isFeatureEnabled(FeatureFlags.VoiceMessages) && messageType.isVoiceMessage -> TimelineItemVoiceContent(
|
||||
eventId = eventId,
|
||||
body = messageType.body,
|
||||
mediaSource = messageType.source,
|
||||
duration = messageType.info?.duration ?: Duration.ZERO,
|
||||
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
|
||||
waveform = messageType.details?.waveform?.toImmutableList() ?: persistentListOf(),
|
||||
)
|
||||
else -> TimelineItemAudioContent(
|
||||
body = messageType.body,
|
||||
mediaSource = messageType.source,
|
||||
duration = messageType.info?.duration ?: Duration.ZERO,
|
||||
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
|
||||
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
|
||||
fileExtension = fileExtensionExtractor.extractFromName(messageType.body),
|
||||
)
|
||||
}
|
||||
is FileMessageType -> {
|
||||
val fileExtension = fileExtensionExtractor.extractFromName(messageType.body)
|
||||
TimelineItemFileContent(
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
|
||||
|
|
@ -58,6 +59,7 @@ internal fun TimelineItem.Event.canBeGrouped(): Boolean {
|
|||
is TimelineItemAudioContent,
|
||||
is TimelineItemLocationContent,
|
||||
is TimelineItemPollContent,
|
||||
is TimelineItemVoiceContent,
|
||||
TimelineItemRedactedContent,
|
||||
TimelineItemUnknownContent -> false
|
||||
is TimelineItemProfileChangeContent,
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ fun TimelineItemEventContent.canBeCopied(): Boolean =
|
|||
*/
|
||||
fun TimelineItemEventContent.canBeRepliedTo(): Boolean =
|
||||
when (this) {
|
||||
is TimelineItemVoiceContent, // TODO Voice messages: swipe to reply disabled for now to avoid conflict with audio scrubbing.
|
||||
is TimelineItemRedactedContent,
|
||||
is TimelineItemStateContent,
|
||||
is TimelineItemPollContent -> false
|
||||
|
|
@ -58,6 +59,7 @@ fun TimelineItemEventContent.canReact(): Boolean =
|
|||
is TimelineItemImageContent,
|
||||
is TimelineItemLocationContent,
|
||||
is TimelineItemPollContent,
|
||||
is TimelineItemVoiceContent,
|
||||
is TimelineItemVideoContent -> true
|
||||
is TimelineItemStateContent,
|
||||
is TimelineItemRedactedContent,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
|
|
@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
|
@ -49,6 +50,7 @@ class MessageSummaryFormatterImpl @Inject constructor(
|
|||
is TimelineItemEncryptedContent -> context.getString(CommonStrings.common_unable_to_decrypt)
|
||||
is TimelineItemRedactedContent -> context.getString(CommonStrings.common_message_removed)
|
||||
is TimelineItemPollContent -> event.content.question
|
||||
is TimelineItemVoiceContent -> context.getString(CommonStrings.common_voice_message)
|
||||
is TimelineItemUnknownContent -> context.getString(CommonStrings.common_unsupported_event)
|
||||
is TimelineItemImageContent -> context.getString(CommonStrings.common_image)
|
||||
is TimelineItemVideoContent -> context.getString(CommonStrings.common_video)
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.voicemessages
|
||||
package io.element.android.features.messages.impl.voicemessages.composer
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||
|
|
@ -22,6 +22,7 @@ import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
|||
@Stable
|
||||
data class VoiceMessageComposerState(
|
||||
val voiceMessageState: VoiceMessageState,
|
||||
val showPermissionRationaleDialog: Boolean,
|
||||
val eventSink: (VoiceMessageComposerEvents) -> Unit,
|
||||
)
|
||||
|
||||
|
|
@ -14,21 +14,24 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.voicemessages
|
||||
package io.element.android.features.messages.impl.voicemessages.composer
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
internal open class VoiceMessageComposerStateProvider : PreviewParameterProvider<VoiceMessageComposerState> {
|
||||
override val values: Sequence<VoiceMessageComposerState>
|
||||
get() = sequenceOf(
|
||||
aVoiceMessageComposerState(voiceMessageState = VoiceMessageState.Recording),
|
||||
aVoiceMessageComposerState(voiceMessageState = VoiceMessageState.Recording(duration = 61.seconds, level = 0.5)),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun aVoiceMessageComposerState(
|
||||
voiceMessageState: VoiceMessageState = VoiceMessageState.Idle,
|
||||
showPermissionRationaleDialog: Boolean = false,
|
||||
) = VoiceMessageComposerState(
|
||||
voiceMessageState = voiceMessageState,
|
||||
showPermissionRationaleDialog = showPermissionRationaleDialog,
|
||||
eventSink = {},
|
||||
)
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {}
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
9
features/messages/impl/src/main/res/drawable/pause.xml
Normal file
9
features/messages/impl/src/main/res/drawable/pause.xml
Normal 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>
|
||||
9
features/messages/impl/src/main/res/drawable/play.xml
Normal file
9
features/messages/impl/src/main/res/drawable/play.xml
Normal 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>
|
||||
9
features/messages/impl/src/main/res/drawable/retry.xml
Normal file
9
features/messages/impl/src/main/res/drawable/retry.xml
Normal 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>
|
||||
|
|
@ -40,7 +40,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerPresenter
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
|
||||
import io.element.android.features.messages.media.FakeLocalMediaFactory
|
||||
import io.element.android.features.messages.textcomposer.TestRichTextEditorStateFactory
|
||||
import io.element.android.features.messages.timeline.components.customreaction.FakeEmojibaseProvider
|
||||
|
|
@ -66,6 +66,7 @@ import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
|||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
||||
|
|
@ -75,6 +76,7 @@ import io.element.android.libraries.permissions.api.PermissionsPresenter
|
|||
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||
|
|
@ -607,20 +609,28 @@ class MessagesPresenterTest {
|
|||
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
|
||||
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
||||
): MessagesPresenter {
|
||||
val mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom)
|
||||
val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter)
|
||||
val messageComposerPresenter = MessageComposerPresenter(
|
||||
appCoroutineScope = this,
|
||||
room = matrixRoom,
|
||||
mediaPickerProvider = FakePickerProvider(),
|
||||
featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.NotificationSettings.key to true)),
|
||||
localMediaFactory = FakeLocalMediaFactory(mockMediaUrl),
|
||||
mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom),
|
||||
mediaSender = mediaSender,
|
||||
snackbarDispatcher = SnackbarDispatcher(),
|
||||
analyticsService = analyticsService,
|
||||
messageComposerContext = MessageComposerContextImpl(),
|
||||
richTextEditorStateFactory = TestRichTextEditorStateFactory(),
|
||||
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
|
||||
permissionsPresenterFactory = permissionsPresenterFactory,
|
||||
)
|
||||
val voiceMessageComposerPresenter = VoiceMessageComposerPresenter(
|
||||
this,
|
||||
FakeVoiceRecorder(),
|
||||
analyticsService,
|
||||
mediaSender,
|
||||
permissionsPresenterFactory,
|
||||
)
|
||||
val voiceMessageComposerPresenter = VoiceMessageComposerPresenter()
|
||||
val timelinePresenter = TimelinePresenter(
|
||||
timelineItemsFactory = aTimelineItemsFactory(),
|
||||
room = matrixRoom,
|
||||
|
|
@ -649,6 +659,7 @@ class MessagesPresenterTest {
|
|||
clipboardHelper = clipboardHelper,
|
||||
preferencesStore = preferencesStore,
|
||||
featureFlagsService = FakeFeatureFlagService(),
|
||||
buildMeta = aBuildMeta(),
|
||||
dispatchers = coroutineDispatchers,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
|
||||
import io.element.android.libraries.featureflag.test.InMemoryPreferencesStore
|
||||
import io.element.android.libraries.matrix.test.A_MESSAGE
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
|
|
@ -458,6 +459,33 @@ class ActionListPresenterTest {
|
|||
assertThat(successState.displayEmojiReactions).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - compute for voice message`() = runTest {
|
||||
val presenter = createActionListPresenter(isDeveloperModeEnabled = false)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
val messageEvent = aMessageEvent(
|
||||
isMine = true,
|
||||
content = aTimelineItemVoiceContent(),
|
||||
)
|
||||
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
|
||||
val successState = awaitItem()
|
||||
assertThat(successState.target).isEqualTo(
|
||||
ActionListState.Target.Success(
|
||||
messageEvent,
|
||||
persistentListOf(
|
||||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.Forward,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
)
|
||||
)
|
||||
assertThat(successState.displayEmojiReactions).isTrue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createActionListPresenter(isDeveloperModeEnabled: Boolean): ActionListPresenter {
|
||||
|
|
|
|||
|
|
@ -49,7 +49,11 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
|
|||
dispatchers = testCoroutineDispatchers(),
|
||||
eventItemFactory = TimelineItemEventFactory(
|
||||
contentFactory = TimelineItemContentFactory(
|
||||
messageFactory = TimelineItemContentMessageFactory(FakeFileSizeFormatter(), FileExtensionExtractorWithoutValidation()),
|
||||
messageFactory = TimelineItemContentMessageFactory(
|
||||
fileSizeFormatter = FakeFileSizeFormatter(),
|
||||
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
|
||||
featureFlagService = FakeFeatureFlagService(),
|
||||
),
|
||||
redactedMessageFactory = TimelineItemContentRedactedFactory(),
|
||||
stickerFactory = TimelineItemContentStickerFactory(),
|
||||
pollFactory = TimelineItemContentPollFactory(matrixClient, FakeFeatureFlagService()),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -18,72 +18,431 @@
|
|||
|
||||
package io.element.android.features.messages.voicemessages
|
||||
|
||||
import android.Manifest
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.TurbineTestContext
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerEvents
|
||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerPresenter
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerEvents
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
|
||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||
import io.element.android.libraries.permissions.api.aPermissionsState
|
||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
||||
import io.element.android.libraries.textcomposer.model.PressEvent
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||
import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class VoiceMessageComposerPresenterTest {
|
||||
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
private val voiceRecorder = FakeVoiceRecorder(
|
||||
recordingDuration = RECORDING_DURATION
|
||||
)
|
||||
private val analyticsService = FakeAnalyticsService()
|
||||
private val matrixRoom = FakeMatrixRoom()
|
||||
private val mediaPreProcessor = FakeMediaPreProcessor().apply { givenAudioResult() }
|
||||
private val mediaSender = MediaSender(mediaPreProcessor, matrixRoom)
|
||||
|
||||
companion object {
|
||||
private val RECORDING_DURATION = 1.seconds
|
||||
private val RECORDING_STATE = VoiceMessageState.Recording(RECORDING_DURATION, 0.2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val presenter = createPresenter()
|
||||
val presenter = createVoiceMessageComposerPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
voiceRecorder.assertCalls(started = 0)
|
||||
|
||||
testPauseAndDestroy(initialState)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - recording state`() = runTest {
|
||||
val presenter = createPresenter()
|
||||
val presenter = createVoiceMessageComposerPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
|
||||
assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Recording)
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
|
||||
voiceRecorder.assertCalls(started = 1)
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - abort recording`() = runTest {
|
||||
val presenter = createPresenter()
|
||||
val presenter = createVoiceMessageComposerPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.Tapped))
|
||||
|
||||
assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - finish recording`() = runTest {
|
||||
val presenter = createPresenter()
|
||||
val presenter = createVoiceMessageComposerPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||
|
||||
assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Preview)
|
||||
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0)
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
}
|
||||
}
|
||||
private fun createPresenter() = VoiceMessageComposerPresenter()
|
||||
|
||||
@Test
|
||||
fun `present - delete recording`() = runTest {
|
||||
val presenter = createVoiceMessageComposerPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage)
|
||||
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - send recording`() = runTest {
|
||||
val presenter = createVoiceMessageComposerPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||
assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Sending)
|
||||
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
assertThat(matrixRoom.sendMediaCount).isEqualTo(1)
|
||||
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - send recording before previous completed, waits`() = runTest {
|
||||
val presenter = createVoiceMessageComposerPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||
awaitItem().run {
|
||||
eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||
eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||
}
|
||||
assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Sending)
|
||||
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
assertThat(matrixRoom.sendMediaCount).isEqualTo(1)
|
||||
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - send failures aren't tracked`() = runTest {
|
||||
// Let sending fail due to media preprocessing error
|
||||
mediaPreProcessor.givenResult(Result.failure(Exception()))
|
||||
val presenter = createVoiceMessageComposerPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||
awaitItem().apply {
|
||||
assertThat(voiceMessageState).isEqualTo(VoiceMessageState.Preview)
|
||||
eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||
}
|
||||
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Sending)
|
||||
assertThat(matrixRoom.sendMediaCount).isEqualTo(0)
|
||||
assertThat(analyticsService.trackedErrors).hasSize(0)
|
||||
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0)
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - send failures can be retried`() = runTest {
|
||||
// Let sending fail due to media preprocessing error
|
||||
val presenter = createVoiceMessageComposerPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
mediaPreProcessor.givenResult(Result.failure(Exception()))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||
val previewState = awaitItem()
|
||||
|
||||
previewState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||
assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Sending)
|
||||
|
||||
ensureAllEventsConsumed()
|
||||
assertThat(previewState.voiceMessageState).isEqualTo(VoiceMessageState.Preview)
|
||||
assertThat(matrixRoom.sendMediaCount).isEqualTo(0)
|
||||
|
||||
mediaPreProcessor.givenAudioResult()
|
||||
previewState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
assertThat(matrixRoom.sendMediaCount).isEqualTo(1)
|
||||
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - send error - missing recording is tracked`() = runTest {
|
||||
val presenter = createVoiceMessageComposerPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
// Send the message before recording anything
|
||||
initialState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||
|
||||
assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
assertThat(matrixRoom.sendMediaCount).isEqualTo(0)
|
||||
assertThat(analyticsService.trackedErrors).hasSize(1)
|
||||
voiceRecorder.assertCalls(started = 0)
|
||||
|
||||
testPauseAndDestroy(initialState)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - record error - security exceptions are tracked`() = runTest {
|
||||
val exception = SecurityException("")
|
||||
voiceRecorder.givenThrowsSecurityException(exception)
|
||||
val presenter = createVoiceMessageComposerPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
|
||||
assertThat(matrixRoom.sendMediaCount).isEqualTo(0)
|
||||
assertThat(analyticsService.trackedErrors).containsExactly(
|
||||
VoiceMessageException.PermissionMissing(message = "Expected permission to record but none", cause = exception)
|
||||
)
|
||||
voiceRecorder.assertCalls(started = 1)
|
||||
|
||||
testPauseAndDestroy(initialState)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - permission accepted first time`() = runTest {
|
||||
val permissionsPresenter = createFakePermissionsPresenter(
|
||||
recordPermissionGranted = false,
|
||||
)
|
||||
val presenter = createVoiceMessageComposerPresenter(
|
||||
permissionsPresenter = permissionsPresenter,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
|
||||
initialState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||
voiceRecorder.assertCalls(stopped = 1)
|
||||
|
||||
permissionsPresenter.setPermissionGranted()
|
||||
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
|
||||
voiceRecorder.assertCalls(stopped = 1, started = 1)
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - permission denied previously`() = runTest {
|
||||
val permissionsPresenter = createFakePermissionsPresenter(
|
||||
recordPermissionGranted = false,
|
||||
)
|
||||
val presenter = createVoiceMessageComposerPresenter(
|
||||
permissionsPresenter = permissionsPresenter,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
|
||||
// See the dialog and accept it
|
||||
awaitItem().also {
|
||||
assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
assertThat(it.showPermissionRationaleDialog).isTrue()
|
||||
it.eventSink(VoiceMessageComposerEvents.AcceptPermissionRationale)
|
||||
}
|
||||
|
||||
// Dialog is hidden, user accepts permissions
|
||||
assertThat(awaitItem().showPermissionRationaleDialog).isFalse()
|
||||
|
||||
permissionsPresenter.setPermissionGranted()
|
||||
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
|
||||
voiceRecorder.assertCalls(started = 1)
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - permission rationale dismissed`() = runTest {
|
||||
val permissionsPresenter = createFakePermissionsPresenter(
|
||||
recordPermissionGranted = false,
|
||||
)
|
||||
val presenter = createVoiceMessageComposerPresenter(
|
||||
permissionsPresenter = permissionsPresenter,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
|
||||
// See the dialog and accept it
|
||||
awaitItem().also {
|
||||
assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
assertThat(it.showPermissionRationaleDialog).isTrue()
|
||||
it.eventSink(VoiceMessageComposerEvents.DismissPermissionsRationale)
|
||||
}
|
||||
|
||||
// Dialog is hidden, user tries to record again
|
||||
awaitItem().also {
|
||||
assertThat(it.showPermissionRationaleDialog).isFalse()
|
||||
it.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
}
|
||||
|
||||
// Dialog is shown once again
|
||||
val finalState = awaitItem().also {
|
||||
assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
assertThat(it.showPermissionRationaleDialog).isTrue()
|
||||
}
|
||||
voiceRecorder.assertCalls(started = 0)
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun TurbineTestContext<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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -15,6 +15,7 @@
|
|||
*/
|
||||
plugins {
|
||||
id("io.element.android-library")
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
android {
|
||||
|
|
|
|||
|
|
@ -16,16 +16,30 @@
|
|||
|
||||
package io.element.android.features.preferences.api
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
interface PreferencesEntryPoint : FeatureEntryPoint {
|
||||
|
||||
sealed interface InitialTarget : Parcelable {
|
||||
@Parcelize
|
||||
data object Root : InitialTarget
|
||||
@Parcelize
|
||||
data object NotificationSettings : InitialTarget
|
||||
}
|
||||
|
||||
data class Params(val initialElement: InitialTarget) : NodeInputs
|
||||
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
|
||||
|
||||
interface NodeBuilder {
|
||||
|
||||
fun params(params: Params): NodeBuilder
|
||||
fun callback(callback: Callback): NodeBuilder
|
||||
fun build(): Node
|
||||
}
|
||||
|
|
@ -33,5 +47,6 @@ interface PreferencesEntryPoint : FeatureEntryPoint {
|
|||
interface Callback : Plugin {
|
||||
fun onOpenBugReport()
|
||||
fun onVerifyClicked()
|
||||
fun onOpenRoomNotificationSettings(roomId: RoomId)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,11 @@ class DefaultPreferencesEntryPoint @Inject constructor() : PreferencesEntryPoint
|
|||
return object : PreferencesEntryPoint.NodeBuilder {
|
||||
val plugins = ArrayList<Plugin>()
|
||||
|
||||
override fun params(params: PreferencesEntryPoint.Params): PreferencesEntryPoint.NodeBuilder {
|
||||
plugins += params
|
||||
return this
|
||||
}
|
||||
|
||||
override fun callback(callback: PreferencesEntryPoint.Callback): PreferencesEntryPoint.NodeBuilder {
|
||||
plugins += callback
|
||||
return this
|
||||
|
|
@ -42,3 +47,8 @@ class DefaultPreferencesEntryPoint @Inject constructor() : PreferencesEntryPoint
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun PreferencesEntryPoint.InitialTarget.toNavTarget() = when (this) {
|
||||
is PreferencesEntryPoint.InitialTarget.Root -> PreferencesFlowNode.NavTarget.Root
|
||||
is PreferencesEntryPoint.InitialTarget.NotificationSettings -> PreferencesFlowNode.NavTarget.NotificationSettings
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import io.element.android.libraries.architecture.BackstackNode
|
|||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
|
|
@ -54,7 +55,7 @@ class PreferencesFlowNode @AssistedInject constructor(
|
|||
private val lockScreenEntryPoint: LockScreenEntryPoint,
|
||||
) : BackstackNode<PreferencesFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = NavTarget.Root,
|
||||
initialElement = plugins.filterIsInstance<PreferencesEntryPoint.Params>().first().initialElement.toNavTarget(),
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
buildContext = buildContext,
|
||||
|
|
@ -161,8 +162,13 @@ class PreferencesFlowNode @AssistedInject constructor(
|
|||
createNode<NotificationSettingsNode>(buildContext, listOf(notificationSettingsCallback))
|
||||
}
|
||||
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)
|
||||
createNode<EditDefaultNotificationSettingNode>(buildContext, plugins = listOf(input))
|
||||
createNode<EditDefaultNotificationSettingNode>(buildContext, plugins = listOf(input, callback))
|
||||
}
|
||||
NavTarget.AdvancedSettings -> {
|
||||
createNode<AdvancedSettingsNode>(buildContext)
|
||||
|
|
|
|||
|
|
@ -24,4 +24,5 @@ sealed interface NotificationSettingsEvents {
|
|||
data class SetCallNotificationsEnabled(val enabled: Boolean) : NotificationSettingsEvents
|
||||
data object FixConfigurationMismatch : NotificationSettingsEvents
|
||||
data object ClearConfigurationMismatchError : NotificationSettingsEvents
|
||||
data object ClearNotificationChangeError : NotificationSettingsEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,9 @@ import androidx.compose.runtime.collectAsState
|
|||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
|
|
@ -50,6 +52,7 @@ class NotificationSettingsPresenter @Inject constructor(
|
|||
val systemNotificationsEnabled: MutableState<Boolean> = remember {
|
||||
mutableStateOf(systemNotificationsEnabledProvider.notificationsEnabled())
|
||||
}
|
||||
val changeNotificationSettingAction: MutableState<Async<Unit>> = remember { mutableStateOf(Async.Uninitialized) }
|
||||
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
val appNotificationsEnabled = userPushStore
|
||||
|
|
@ -67,8 +70,12 @@ class NotificationSettingsPresenter @Inject constructor(
|
|||
|
||||
fun handleEvents(event: NotificationSettingsEvents) {
|
||||
when (event) {
|
||||
is NotificationSettingsEvents.SetAtRoomNotificationsEnabled -> localCoroutineScope.setAtRoomNotificationsEnabled(event.enabled)
|
||||
is NotificationSettingsEvents.SetCallNotificationsEnabled -> localCoroutineScope.setCallNotificationsEnabled(event.enabled)
|
||||
is NotificationSettingsEvents.SetAtRoomNotificationsEnabled -> {
|
||||
localCoroutineScope.setAtRoomNotificationsEnabled(event.enabled, changeNotificationSettingAction)
|
||||
}
|
||||
is NotificationSettingsEvents.SetCallNotificationsEnabled -> {
|
||||
localCoroutineScope.setCallNotificationsEnabled(event.enabled, changeNotificationSettingAction)
|
||||
}
|
||||
is NotificationSettingsEvents.SetNotificationsEnabled -> localCoroutineScope.setNotificationsEnabled(userPushStore, event.enabled)
|
||||
NotificationSettingsEvents.ClearConfigurationMismatchError -> {
|
||||
matrixSettings.value = NotificationSettingsState.MatrixSettings.Invalid(fixFailed = false)
|
||||
|
|
@ -77,6 +84,7 @@ class NotificationSettingsPresenter @Inject constructor(
|
|||
NotificationSettingsEvents.RefreshSystemNotificationsEnabled -> {
|
||||
systemNotificationsEnabled.value = systemNotificationsEnabledProvider.notificationsEnabled()
|
||||
}
|
||||
NotificationSettingsEvents.ClearNotificationChangeError -> changeNotificationSettingAction.value = Async.Uninitialized
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -86,6 +94,7 @@ class NotificationSettingsPresenter @Inject constructor(
|
|||
systemNotificationsEnabled = systemNotificationsEnabled.value,
|
||||
appNotificationsEnabled = appNotificationsEnabled.value
|
||||
),
|
||||
changeNotificationSettingAction = changeNotificationSettingAction.value,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
|
@ -154,12 +163,16 @@ class NotificationSettingsPresenter @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.setAtRoomNotificationsEnabled(enabled: Boolean) = launch {
|
||||
notificationSettingsService.setRoomMentionEnabled(enabled)
|
||||
private fun CoroutineScope.setAtRoomNotificationsEnabled(enabled: Boolean, action: MutableState<Async<Unit>>) = launch {
|
||||
suspend {
|
||||
notificationSettingsService.setRoomMentionEnabled(enabled).getOrThrow()
|
||||
}.runCatchingUpdatingState(action)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.setCallNotificationsEnabled(enabled: Boolean) = launch {
|
||||
notificationSettingsService.setCallEnabled(enabled)
|
||||
private fun CoroutineScope.setCallNotificationsEnabled(enabled: Boolean, action: MutableState<Async<Unit>>) = launch {
|
||||
suspend {
|
||||
notificationSettingsService.setCallEnabled(enabled).getOrThrow()
|
||||
}.runCatchingUpdatingState(action)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.setNotificationsEnabled(userPushStore: UserPushStore, enabled: Boolean) = launch {
|
||||
|
|
|
|||
|
|
@ -17,12 +17,14 @@
|
|||
package io.element.android.features.preferences.impl.notifications
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
|
||||
@Immutable
|
||||
data class NotificationSettingsState(
|
||||
val matrixSettings: MatrixSettings,
|
||||
val appSettings: AppSettings,
|
||||
val changeNotificationSettingAction: Async<Unit>,
|
||||
val eventSink: (NotificationSettingsEvents) -> Unit,
|
||||
) {
|
||||
sealed interface MatrixSettings {
|
||||
|
|
|
|||
|
|
@ -17,16 +17,21 @@
|
|||
package io.element.android.features.preferences.impl.notifications
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
|
||||
open class NotificationSettingsStateProvider : PreviewParameterProvider<NotificationSettingsState> {
|
||||
override val values: Sequence<NotificationSettingsState>
|
||||
get() = sequenceOf(
|
||||
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(
|
||||
atRoomNotificationsEnabled = true,
|
||||
callNotificationsEnabled = true,
|
||||
|
|
@ -37,5 +42,6 @@ fun aNotificationSettingsState() = NotificationSettingsState(
|
|||
systemNotificationsEnabled = false,
|
||||
appNotificationsEnabled = true,
|
||||
),
|
||||
changeNotificationSettingAction = changeNotificationSettingAction,
|
||||
eventSink = {}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import io.element.android.libraries.androidutils.system.startNotificationSettingsIntent
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
|
||||
|
|
@ -87,9 +89,23 @@ fun NotificationSettingsView(
|
|||
onGroupChatsClicked = { onOpenEditDefault(false) },
|
||||
onDirectChatsClicked = { onOpenEditDefault(true) },
|
||||
onMentionNotificationsChanged = { state.eventSink(NotificationSettingsEvents.SetAtRoomNotificationsEnabled(it)) },
|
||||
// TODO We are removing the call notification toggle until support for call notifications has been added
|
||||
// onCallsNotificationsChanged = { state.eventSink(NotificationSettingsEvents.SetCallNotificationsEnabled(it)) },
|
||||
)
|
||||
}
|
||||
when (state.changeNotificationSettingAction) {
|
||||
is Async.Loading -> {
|
||||
ProgressDialog()
|
||||
}
|
||||
is Async.Failure -> {
|
||||
ErrorDialog(
|
||||
title = stringResource(CommonStrings.dialog_title_error),
|
||||
content = stringResource(CommonStrings.screen_notification_settings_edit_failed_updating_default_mode),
|
||||
onDismiss = { state.eventSink(NotificationSettingsEvents.ClearNotificationChangeError) },
|
||||
)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -101,6 +117,7 @@ private fun NotificationSettingsContentView(
|
|||
onGroupChatsClicked: () -> Unit,
|
||||
onDirectChatsClicked: () -> Unit,
|
||||
onMentionNotificationsChanged: (Boolean) -> Unit,
|
||||
// TODO We are removing the call notification toggle until support for call notifications has been added
|
||||
// onCallsNotificationsChanged: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
|
@ -151,7 +168,7 @@ private fun NotificationSettingsContentView(
|
|||
onCheckedChange = onMentionNotificationsChanged
|
||||
)
|
||||
}
|
||||
// We are removing the call notification toggle until call support has been added
|
||||
// TODO We are removing the call notification toggle until support for call notifications has been added
|
||||
// PreferenceCategory(title = stringResource(id = CommonStrings.screen_notification_settings_additional_settings_section_title)) {
|
||||
// PreferenceSwitch(
|
||||
// modifier = Modifier,
|
||||
|
|
|
|||
|
|
@ -21,12 +21,14 @@ import androidx.compose.ui.Modifier
|
|||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.core.plugin.plugins
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
class EditDefaultNotificationSettingNode @AssistedInject constructor(
|
||||
|
|
@ -35,20 +37,30 @@ class EditDefaultNotificationSettingNode @AssistedInject constructor(
|
|||
presenterFactory: EditDefaultNotificationSettingPresenter.Factory
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun openRoomNotificationSettings(roomId: RoomId)
|
||||
}
|
||||
|
||||
data class Inputs(
|
||||
val isOneToOne: Boolean
|
||||
) : NodeInputs
|
||||
|
||||
private val inputs = inputs<Inputs>()
|
||||
private val callbacks = plugins<Callback>()
|
||||
private val presenter = presenterFactory.create(inputs.isOneToOne)
|
||||
|
||||
private fun openRoomNotificationSettings(roomId: RoomId) {
|
||||
callbacks.forEach { it.openRoomNotificationSettings(roomId) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
EditDefaultNotificationSettingView(
|
||||
state = state,
|
||||
openRoomNotificationSettings = { openRoomNotificationSettings(it) },
|
||||
onBackPressed = ::navigateUp,
|
||||
modifier = modifier
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,20 +25,28 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.Collator
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class EditDefaultNotificationSettingPresenter @AssistedInject constructor(
|
||||
private val notificationSettingsService: NotificationSettingsService,
|
||||
@Assisted private val isOneToOne: Boolean,
|
||||
private val roomListService: RoomListService,
|
||||
private val matrixClient: MatrixClient,
|
||||
) : Presenter<EditDefaultNotificationSettingState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
|
|
@ -50,21 +58,34 @@ class EditDefaultNotificationSettingPresenter @AssistedInject constructor(
|
|||
val mode: MutableState<RoomNotificationMode?> = remember {
|
||||
mutableStateOf(null)
|
||||
}
|
||||
|
||||
val changeNotificationSettingAction: MutableState<Async<Unit>> = remember { mutableStateOf(Async.Uninitialized) }
|
||||
|
||||
val roomsWithUserDefinedMode: MutableState<List<RoomSummary.Filled>> = remember {
|
||||
mutableStateOf(listOf())
|
||||
}
|
||||
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
LaunchedEffect(Unit) {
|
||||
fetchSettings(mode)
|
||||
observeNotificationSettings(mode)
|
||||
observeRoomSummaries(roomsWithUserDefinedMode)
|
||||
}
|
||||
|
||||
fun handleEvents(event: EditDefaultNotificationSettingStateEvents) {
|
||||
when (event) {
|
||||
is EditDefaultNotificationSettingStateEvents.SetNotificationMode -> localCoroutineScope.setDefaultNotificationMode(event.mode)
|
||||
is EditDefaultNotificationSettingStateEvents.SetNotificationMode -> {
|
||||
localCoroutineScope.setDefaultNotificationMode(event.mode, changeNotificationSettingAction)
|
||||
}
|
||||
EditDefaultNotificationSettingStateEvents.ClearError -> changeNotificationSettingAction.value = Async.Uninitialized
|
||||
}
|
||||
}
|
||||
|
||||
return EditDefaultNotificationSettingState(
|
||||
isOneToOne = isOneToOne,
|
||||
mode = mode.value,
|
||||
roomsWithUserDefinedMode = roomsWithUserDefinedMode.value,
|
||||
changeNotificationSettingAction = changeNotificationSettingAction.value,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
|
@ -83,10 +104,39 @@ class EditDefaultNotificationSettingPresenter @AssistedInject constructor(
|
|||
.launchIn(this)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.setDefaultNotificationMode(mode: RoomNotificationMode) = launch {
|
||||
// On modern clients, we don't have different settings for encrypted and non-encrypted rooms (Legacy clients did).
|
||||
notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = true, mode = mode, isOneToOne = isOneToOne)
|
||||
notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = false, mode = mode, isOneToOne = isOneToOne)
|
||||
private fun CoroutineScope.observeRoomSummaries(roomsWithUserDefinedMode: MutableState<List<RoomSummary.Filled>>) {
|
||||
roomListService.allRooms()
|
||||
.summaries
|
||||
.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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,10 +16,14 @@
|
|||
|
||||
package io.element.android.features.preferences.impl.notifications.edit
|
||||
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
|
||||
|
||||
data class EditDefaultNotificationSettingState(
|
||||
val isOneToOne: Boolean,
|
||||
val mode: RoomNotificationMode?,
|
||||
val roomsWithUserDefinedMode: List<RoomSummary.Filled>,
|
||||
val changeNotificationSettingAction: Async<Unit>,
|
||||
val eventSink: (EditDefaultNotificationSettingStateEvents) -> Unit,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -20,4 +20,5 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
|||
|
||||
sealed interface EditDefaultNotificationSettingStateEvents {
|
||||
data class SetNotificationMode(val mode: RoomNotificationMode): EditDefaultNotificationSettingStateEvents
|
||||
data object ClearError: EditDefaultNotificationSettingStateEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
|
|
@ -21,8 +21,21 @@ import androidx.compose.foundation.selection.selectableGroup
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.theme.components.ListItem
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
|
|
@ -33,11 +46,12 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
|||
@Composable
|
||||
fun EditDefaultNotificationSettingView(
|
||||
state: EditDefaultNotificationSettingState,
|
||||
openRoomNotificationSettings:(roomId: RoomId) -> Unit,
|
||||
onBackPressed: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
||||
val title = if(state.isOneToOne) {
|
||||
val title = if (state.isOneToOne) {
|
||||
CommonStrings.screen_notification_settings_direct_chats
|
||||
} else {
|
||||
CommonStrings.screen_notification_settings_group_chats
|
||||
|
|
@ -51,7 +65,7 @@ fun EditDefaultNotificationSettingView(
|
|||
// Only ALL_MESSAGES and MENTIONS_AND_KEYWORDS_ONLY are valid global defaults.
|
||||
val validModes = listOf(RoomNotificationMode.ALL_MESSAGES, RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
|
||||
|
||||
val categoryTitle = if(state.isOneToOne) {
|
||||
val categoryTitle = if (state.isOneToOne) {
|
||||
CommonStrings.screen_notification_settings_edit_screen_direct_section_header
|
||||
} else {
|
||||
CommonStrings.screen_notification_settings_edit_screen_group_section_header
|
||||
|
|
@ -70,6 +84,63 @@ fun EditDefaultNotificationSettingView(
|
|||
}
|
||||
}
|
||||
}
|
||||
if (state.roomsWithUserDefinedMode.isNotEmpty()) {
|
||||
PreferenceCategory(title = stringResource(id = CommonStrings.screen_notification_settings_edit_custom_settings_section_title)) {
|
||||
state.roomsWithUserDefinedMode.forEach { summary ->
|
||||
val subtitle = when (summary.details.notificationMode) {
|
||||
RoomNotificationMode.ALL_MESSAGES -> stringResource(id = CommonStrings.screen_notification_settings_edit_mode_all_messages)
|
||||
RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> {
|
||||
stringResource(id = CommonStrings.screen_notification_settings_edit_mode_mentions_and_keywords)
|
||||
}
|
||||
RoomNotificationMode.MUTE -> stringResource(id = CommonStrings.common_mute)
|
||||
null -> ""
|
||||
}
|
||||
val avatarData = AvatarData(
|
||||
id = summary.identifier(),
|
||||
name = summary.details.name,
|
||||
url = summary.details.avatarURLString,
|
||||
size = AvatarSize.CustomRoomNotificationSetting,
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(text = summary.details.name)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(text = subtitle)
|
||||
},
|
||||
leadingContent = ListItemContent.Custom {
|
||||
Avatar(avatarData = avatarData)
|
||||
},
|
||||
onClick = {
|
||||
openRoomNotificationSettings(summary.details.roomId)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
when (state.changeNotificationSettingAction) {
|
||||
is Async.Loading -> {
|
||||
ProgressDialog()
|
||||
}
|
||||
is Async.Failure -> {
|
||||
ErrorDialog(
|
||||
title = stringResource(CommonStrings.dialog_title_error),
|
||||
content = stringResource(CommonStrings.screen_notification_settings_edit_failed_updating_default_mode),
|
||||
onDismiss = { state.eventSink(EditDefaultNotificationSettingStateEvents.ClearError) },
|
||||
)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun EditDefaultNotificationSettingViewPreview(
|
||||
@PreviewParameter(EditDefaultNotificationSettingStateProvider::class) state: EditDefaultNotificationSettingState
|
||||
) = ElementPreview {
|
||||
EditDefaultNotificationSettingView(
|
||||
state = state,
|
||||
openRoomNotificationSettings = {},
|
||||
onBackPressed = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,14 @@ import com.google.common.truth.Truth
|
|||
import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingPresenter
|
||||
import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingStateEvents
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.room.aRoomSummaryDetail
|
||||
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
|
||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
|
@ -32,7 +39,7 @@ class EditDefaultNotificationSettingsPresenterTests {
|
|||
@Test
|
||||
fun `present - ensures initial state is correct`() = runTest {
|
||||
val notificationSettingsService = FakeNotificationSettingsService()
|
||||
val presenter = EditDefaultNotificationSettingPresenter(notificationSettingsService = notificationSettingsService, isOneToOne = false)
|
||||
val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
|
|
@ -47,10 +54,32 @@ class EditDefaultNotificationSettingsPresenterTests {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - ensure list of rooms with user defined mode`() = runTest {
|
||||
val room = FakeMatrixRoom()
|
||||
val notificationSettingsService = FakeNotificationSettingsService(
|
||||
initialRoomMode = RoomNotificationMode.ALL_MESSAGES,
|
||||
initialRoomModeIsDefault = false
|
||||
)
|
||||
val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService).apply {
|
||||
givenGetRoomResult(A_ROOM_ID, room)
|
||||
}
|
||||
val roomListService = FakeRoomListService()
|
||||
val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService, roomListService, matrixClient)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
roomListService.postAllRooms(listOf(RoomSummary.Filled(aRoomSummaryDetail(notificationMode = RoomNotificationMode.ALL_MESSAGES))))
|
||||
val loadedState = consumeItemsUntilPredicate { state ->
|
||||
state.roomsWithUserDefinedMode.any { it.details.notificationMode == RoomNotificationMode.ALL_MESSAGES }
|
||||
}.last()
|
||||
Truth.assertThat(loadedState.roomsWithUserDefinedMode.any { it.details.notificationMode == RoomNotificationMode.ALL_MESSAGES }).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - edit default notification setting`() = runTest {
|
||||
val notificationSettingsService = FakeNotificationSettingsService()
|
||||
val presenter = EditDefaultNotificationSettingPresenter(notificationSettingsService = notificationSettingsService, isOneToOne = false)
|
||||
val presenter = createEditDefaultNotificationSettingPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
|
|
@ -61,4 +90,39 @@ class EditDefaultNotificationSettingsPresenterTests {
|
|||
Truth.assertThat(loadedState.mode).isEqualTo(RoomNotificationMode.ALL_MESSAGES)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - edit default notification setting failed`() = runTest {
|
||||
val notificationSettingsService = FakeNotificationSettingsService()
|
||||
val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService)
|
||||
notificationSettingsService.givenSetDefaultNotificationModeError(A_THROWABLE)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink(EditDefaultNotificationSettingStateEvents.SetNotificationMode(RoomNotificationMode.ALL_MESSAGES))
|
||||
val errorState = consumeItemsUntilPredicate {
|
||||
it.changeNotificationSettingAction.isFailure()
|
||||
}.last()
|
||||
Truth.assertThat(errorState.changeNotificationSettingAction.isFailure()).isTrue()
|
||||
errorState.eventSink(EditDefaultNotificationSettingStateEvents.ClearError)
|
||||
val clearErrorState = consumeItemsUntilPredicate {
|
||||
it.changeNotificationSettingAction.isUninitialized()
|
||||
}.last()
|
||||
Truth.assertThat(clearErrorState.changeNotificationSettingAction.isUninitialized()).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createEditDefaultNotificationSettingPresenter(
|
||||
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
|
||||
roomListService: FakeRoomListService = FakeRoomListService(),
|
||||
matrixClient: FakeMatrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService)
|
||||
): EditDefaultNotificationSettingPresenter {
|
||||
return EditDefaultNotificationSettingPresenter(
|
||||
notificationSettingsService = notificationSettingsService,
|
||||
isOneToOne = false,
|
||||
roomListService = roomListService,
|
||||
matrixClient = matrixClient
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import app.cash.turbine.test
|
|||
import com.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory
|
||||
import com.google.common.truth.Truth
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
|
||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||
|
|
@ -187,6 +188,35 @@ class NotificationSettingsPresenterTests {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - clear notification settings change error`() = runTest {
|
||||
val notificationSettingsService = FakeNotificationSettingsService()
|
||||
val presenter = createNotificationSettingsPresenter(notificationSettingsService)
|
||||
notificationSettingsService.givenSetAtRoomError(A_THROWABLE)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val loadedState = consumeItemsUntilPredicate {
|
||||
(it.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid)?.atRoomNotificationsEnabled == false
|
||||
}.last()
|
||||
val validMatrixState = loadedState.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid
|
||||
Truth.assertThat(validMatrixState?.atRoomNotificationsEnabled).isFalse()
|
||||
|
||||
loadedState.eventSink(NotificationSettingsEvents.SetAtRoomNotificationsEnabled(true))
|
||||
val errorState = consumeItemsUntilPredicate {
|
||||
it.changeNotificationSettingAction.isFailure()
|
||||
}.last()
|
||||
Truth.assertThat(errorState.changeNotificationSettingAction.isFailure()).isTrue()
|
||||
errorState.eventSink(NotificationSettingsEvents.ClearNotificationChangeError)
|
||||
|
||||
val clearErrorState = consumeItemsUntilPredicate {
|
||||
it.changeNotificationSettingAction.isUninitialized()
|
||||
}.last()
|
||||
Truth.assertThat(clearErrorState.changeNotificationSettingAction.isUninitialized()).isTrue()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotificationSettingsPresenter(
|
||||
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService()
|
||||
) : NotificationSettingsPresenter {
|
||||
|
|
|
|||
|
|
@ -33,9 +33,22 @@ interface RoomDetailsEntryPoint : FeatureEntryPoint {
|
|||
|
||||
@Parcelize
|
||||
data class RoomMemberDetails(val roomMemberId: UserId) : InitialTarget
|
||||
|
||||
@Parcelize
|
||||
data object RoomNotificationSettings : InitialTarget
|
||||
}
|
||||
|
||||
data class Inputs(val initialElement: InitialTarget) : NodeInputs
|
||||
data class Params(val initialElement: InitialTarget) : NodeInputs
|
||||
|
||||
fun createNode(parentNode: Node, buildContext: BuildContext, inputs: Inputs, plugins: List<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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ dependencies {
|
|||
implementation(projects.libraries.mediaupload.api)
|
||||
implementation(projects.libraries.featureflag.api)
|
||||
implementation(projects.libraries.permissions.api)
|
||||
implementation(projects.libraries.preferences.api)
|
||||
api(projects.features.roomdetails.api)
|
||||
api(projects.libraries.usersearch.api)
|
||||
api(projects.services.apperror.api)
|
||||
|
|
|
|||
|
|
@ -29,17 +29,30 @@ import javax.inject.Inject
|
|||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultRoomDetailsEntryPoint @Inject constructor() : RoomDetailsEntryPoint {
|
||||
override fun createNode(
|
||||
parentNode: Node,
|
||||
buildContext: BuildContext,
|
||||
inputs: RoomDetailsEntryPoint.Inputs,
|
||||
plugins: List<Plugin>
|
||||
): Node {
|
||||
return parentNode.createNode<RoomDetailsFlowNode>(buildContext, plugins + inputs)
|
||||
|
||||
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): RoomDetailsEntryPoint.NodeBuilder {
|
||||
return object : RoomDetailsEntryPoint.NodeBuilder {
|
||||
val plugins = ArrayList<Plugin>()
|
||||
|
||||
override fun params(params: RoomDetailsEntryPoint.Params): RoomDetailsEntryPoint.NodeBuilder {
|
||||
plugins += params
|
||||
return this
|
||||
}
|
||||
|
||||
override fun callback(callback: RoomDetailsEntryPoint.Callback): RoomDetailsEntryPoint.NodeBuilder {
|
||||
plugins += callback
|
||||
return this
|
||||
}
|
||||
|
||||
override fun build(): Node {
|
||||
return parentNode.createNode<RoomDetailsFlowNode>(buildContext, plugins)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun InitialTarget.toNavTarget() = when (this) {
|
||||
is InitialTarget.RoomDetails -> NavTarget.RoomDetails
|
||||
is InitialTarget.RoomMemberDetails -> NavTarget.RoomMemberDetails(roomMemberId)
|
||||
is InitialTarget.RoomNotificationSettings -> NavTarget.RoomNotificationSettings(showUserDefinedSettingStyle = true)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import com.bumble.appyx.core.composable.Children
|
|||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.core.plugin.plugins
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
import com.bumble.appyx.navmodel.backstack.operation.push
|
||||
import dagger.assisted.Assisted
|
||||
|
|
@ -47,7 +48,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
|||
@Assisted plugins: List<Plugin>,
|
||||
) : BackstackNode<RoomDetailsFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Inputs>().first().initialElement.toNavTarget(),
|
||||
initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Params>().first().initialElement.toNavTarget(),
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
buildContext = buildContext,
|
||||
|
|
@ -68,7 +69,13 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
|||
data object InviteMembers : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object RoomNotificationSettings : NavTarget
|
||||
data class RoomNotificationSettings(
|
||||
/**
|
||||
* When presented from outsite the context of the room, the rooms settings UI is different.
|
||||
* Figma designs: https://www.figma.com/file/0MMNu7cTOzLOlWb7ctTkv3/Element-X?type=design&node-id=5199-198932&mode=design&t=fTTvpuxYFjewYQOe-0
|
||||
*/
|
||||
val showUserDefinedSettingStyle: Boolean
|
||||
) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class RoomMemberDetails(val roomMemberId: UserId) : NavTarget
|
||||
|
|
@ -91,7 +98,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
|||
}
|
||||
|
||||
override fun openRoomNotificationSettings() {
|
||||
backstack.push(NavTarget.RoomNotificationSettings)
|
||||
backstack.push(NavTarget.RoomNotificationSettings(showUserDefinedSettingStyle = false))
|
||||
}
|
||||
}
|
||||
createNode<RoomDetailsNode>(buildContext, listOf(roomDetailsCallback))
|
||||
|
|
@ -118,8 +125,14 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
|||
createNode<RoomInviteMembersNode>(buildContext)
|
||||
}
|
||||
|
||||
NavTarget.RoomNotificationSettings -> {
|
||||
createNode<RoomNotificationSettingsNode>(buildContext)
|
||||
is NavTarget.RoomNotificationSettings -> {
|
||||
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 -> {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,11 @@ open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState>
|
|||
aDmRoomDetailsState().copy(roomName = "Daniel"),
|
||||
aDmRoomDetailsState(isDmMemberIgnored = true).copy(roomName = "Daniel"),
|
||||
aRoomDetailsState().copy(canInvite = true),
|
||||
aRoomDetailsState().copy(canEdit = true),
|
||||
aRoomDetailsState().copy(
|
||||
canEdit = true,
|
||||
// Also test the roomNotificationSettings ALL_MESSAGES in the same screenshot. Icon 'Mute' should be displayed
|
||||
roomNotificationSettings = RoomNotificationSettings(mode = RoomNotificationMode.ALL_MESSAGES, isDefault = true)
|
||||
),
|
||||
// Add other state here
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ package io.element.android.features.roomdetails.impl
|
|||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
|
|
@ -76,7 +75,6 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
|||
import io.element.android.libraries.theme.ElementTheme
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun RoomDetailsView(
|
||||
state: RoomDetailsState,
|
||||
|
|
|
|||
|
|
@ -21,4 +21,7 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
|||
sealed interface RoomNotificationSettingsEvents {
|
||||
data class RoomNotificationModeChanged(val mode: RoomNotificationMode) : RoomNotificationSettingsEvents
|
||||
data class SetNotificationMode(val isDefault: Boolean): RoomNotificationSettingsEvents
|
||||
data object DeleteCustomNotification: RoomNotificationSettingsEvents
|
||||
data object ClearSetNotificationError: RoomNotificationSettingsEvents
|
||||
data object ClearRestoreDefaultError: RoomNotificationSettingsEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,10 +22,13 @@ import com.bumble.appyx.core.lifecycle.subscribe
|
|||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.core.plugin.plugins
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import im.vector.app.features.analytics.plan.MobileScreen
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
|
||||
|
|
@ -33,10 +36,24 @@ import io.element.android.services.analytics.api.AnalyticsService
|
|||
class RoomNotificationSettingsNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val presenter: RoomNotificationSettingsPresenter,
|
||||
presenterFactory: RoomNotificationSettingsPresenter.Factory,
|
||||
private val analyticsService: AnalyticsService,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
|
||||
data class RoomNotificationSettingInput(
|
||||
val showUserDefinedSettingStyle: Boolean
|
||||
) : NodeInputs
|
||||
interface Callback : Plugin {
|
||||
fun openGlobalNotificationSettings()
|
||||
}
|
||||
private val inputs = inputs<RoomNotificationSettingInput>()
|
||||
private val callbacks = plugins<Callback>()
|
||||
|
||||
private fun openGlobalNotificationSettings() {
|
||||
callbacks.forEach { it.openGlobalNotificationSettings() }
|
||||
}
|
||||
|
||||
private val presenter = presenterFactory.create(inputs.showUserDefinedSettingStyle)
|
||||
init {
|
||||
lifecycle.subscribe(
|
||||
onResume = {
|
||||
|
|
@ -51,6 +68,7 @@ class RoomNotificationSettingsNode @AssistedInject constructor(
|
|||
RoomNotificationSettingsView(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
onShowGlobalNotifications = this::openGlobalNotificationSettings,
|
||||
onBackPressed = this::navigateUp,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -19,90 +19,176 @@ package io.element.android.features.roomdetails.impl.notificationsettings
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class RoomNotificationSettingsPresenter @Inject constructor(
|
||||
class RoomNotificationSettingsPresenter @AssistedInject constructor(
|
||||
private val room: MatrixRoom,
|
||||
private val notificationSettingsService: NotificationSettingsService,
|
||||
@Assisted private val showUserDefinedSettingStyle: Boolean,
|
||||
) : Presenter<RoomNotificationSettingsState> {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(showUserDefinedSettingStyle: Boolean): RoomNotificationSettingsPresenter
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun present(): RoomNotificationSettingsState {
|
||||
val defaultRoomNotificationMode: MutableState<RoomNotificationMode?> = rememberSaveable {
|
||||
mutableStateOf(null)
|
||||
}
|
||||
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) {
|
||||
getDefaultRoomNotificationMode(defaultRoomNotificationMode)
|
||||
observeNotificationSettings()
|
||||
fetchNotificationSettings(pendingRoomNotificationMode, roomNotificationSettings)
|
||||
observeNotificationSettings(pendingRoomNotificationMode, roomNotificationSettings)
|
||||
}
|
||||
|
||||
val roomNotificationSettingsState by room.roomNotificationSettingsStateFlow.collectAsState()
|
||||
|
||||
fun handleEvents(event: RoomNotificationSettingsEvents) {
|
||||
when (event) {
|
||||
is RoomNotificationSettingsEvents.RoomNotificationModeChanged -> {
|
||||
localCoroutineScope.setRoomNotificationMode(event.mode)
|
||||
localCoroutineScope.setRoomNotificationMode(event.mode, pendingRoomNotificationMode, pendingSetDefault, setNotificationSettingAction)
|
||||
}
|
||||
is RoomNotificationSettingsEvents.SetNotificationMode -> {
|
||||
if (event.isDefault) {
|
||||
localCoroutineScope.restoreDefaultRoomNotificationMode()
|
||||
localCoroutineScope.restoreDefaultRoomNotificationMode(restoreDefaultAction, pendingSetDefault)
|
||||
} else {
|
||||
defaultRoomNotificationMode.value?.let {
|
||||
localCoroutineScope.setRoomNotificationMode(it)
|
||||
localCoroutineScope.setRoomNotificationMode(it, pendingRoomNotificationMode, pendingSetDefault, setNotificationSettingAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
is RoomNotificationSettingsEvents.DeleteCustomNotification -> {
|
||||
localCoroutineScope.restoreDefaultRoomNotificationMode(restoreDefaultAction, pendingSetDefault)
|
||||
}
|
||||
RoomNotificationSettingsEvents.ClearSetNotificationError -> {
|
||||
setNotificationSettingAction.value = Async.Uninitialized
|
||||
}
|
||||
RoomNotificationSettingsEvents.ClearRestoreDefaultError -> {
|
||||
restoreDefaultAction.value = Async.Uninitialized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return RoomNotificationSettingsState(
|
||||
roomNotificationSettings = roomNotificationSettingsState.roomNotificationSettings(),
|
||||
showUserDefinedSettingStyle = showUserDefinedSettingStyle,
|
||||
roomName = room.displayName,
|
||||
roomNotificationSettings = roomNotificationSettings.value,
|
||||
pendingRoomNotificationMode = pendingRoomNotificationMode.value,
|
||||
pendingSetDefault = pendingSetDefault.value,
|
||||
defaultRoomNotificationMode = defaultRoomNotificationMode.value,
|
||||
setNotificationSettingAction = setNotificationSettingAction.value,
|
||||
restoreDefaultAction = restoreDefaultAction.value,
|
||||
eventSink = ::handleEvents,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
private fun CoroutineScope.observeNotificationSettings() {
|
||||
private fun CoroutineScope.observeNotificationSettings(
|
||||
pendingModeState: MutableState<RoomNotificationMode?>,
|
||||
roomNotificationSettings: MutableState<Async<RoomNotificationSettings>>
|
||||
) {
|
||||
notificationSettingsService.notificationSettingsChangeFlow
|
||||
.debounce(0.5.seconds)
|
||||
.onEach {
|
||||
room.updateRoomNotificationSettings()
|
||||
fetchNotificationSettings(pendingModeState, roomNotificationSettings)
|
||||
}
|
||||
.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(
|
||||
room.isEncrypted,
|
||||
room.isOneToOne
|
||||
).getOrThrow()
|
||||
}
|
||||
|
||||
private fun CoroutineScope.setRoomNotificationMode(mode: RoomNotificationMode) = launch {
|
||||
notificationSettingsService.setRoomNotificationMode(room.roomId, mode)
|
||||
private fun CoroutineScope.setRoomNotificationMode(
|
||||
mode: RoomNotificationMode,
|
||||
pendingModeState: MutableState<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 {
|
||||
notificationSettingsService.restoreDefaultRoomNotificationMode(room.roomId)
|
||||
private fun CoroutineScope.restoreDefaultRoomNotificationMode(
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,11 +16,26 @@
|
|||
|
||||
package io.element.android.features.roomdetails.impl.notificationsettings
|
||||
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
|
||||
|
||||
data class RoomNotificationSettingsState(
|
||||
val roomNotificationSettings: RoomNotificationSettings?,
|
||||
val showUserDefinedSettingStyle: Boolean,
|
||||
val roomName: String,
|
||||
val roomNotificationSettings: Async<RoomNotificationSettings>,
|
||||
val pendingRoomNotificationMode: RoomNotificationMode?,
|
||||
val pendingSetDefault: Boolean?,
|
||||
val defaultRoomNotificationMode: RoomNotificationMode?,
|
||||
val setNotificationSettingAction: Async<Unit>,
|
||||
val restoreDefaultAction: Async<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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,18 +17,38 @@
|
|||
package io.element.android.features.roomdetails.impl.notificationsettings
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
|
||||
|
||||
internal class RoomNotificationSettingsStateProvider : PreviewParameterProvider<RoomNotificationSettingsState> {
|
||||
override val values: Sequence<RoomNotificationSettingsState>
|
||||
get() = sequenceOf(
|
||||
RoomNotificationSettingsState(
|
||||
RoomNotificationSettings(
|
||||
mode = RoomNotificationMode.MUTE,
|
||||
isDefault = true),
|
||||
RoomNotificationMode.ALL_MESSAGES,
|
||||
eventSink = { },
|
||||
),
|
||||
aRoomNotificationSettingsState(),
|
||||
aRoomNotificationSettingsState(isDefault = false),
|
||||
aRoomNotificationSettingsState(setNotificationSettingAction = Async.Loading(Unit)),
|
||||
aRoomNotificationSettingsState(setNotificationSettingAction = Async.Failure(Throwable("error"))),
|
||||
aRoomNotificationSettingsState(restoreDefaultAction = Async.Loading(Unit)),
|
||||
aRoomNotificationSettingsState(restoreDefaultAction = Async.Failure(Throwable("error"))),
|
||||
)
|
||||
|
||||
private fun aRoomNotificationSettingsState(
|
||||
isDefault: Boolean = true,
|
||||
setNotificationSettingAction: Async<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 = { },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,25 +18,29 @@ package io.element.android.features.roomdetails.impl.notificationsettings
|
|||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.roomdetails.impl.R
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.core.bool.orTrue
|
||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart
|
||||
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
|
|
@ -45,11 +49,35 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
|||
import io.element.android.libraries.theme.ElementTheme
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
|
||||
@Composable
|
||||
fun RoomNotificationSettingsView(
|
||||
state: RoomNotificationSettingsState,
|
||||
modifier: Modifier = Modifier,
|
||||
onShowGlobalNotifications: () -> Unit = {},
|
||||
onBackPressed: () -> Unit = {},
|
||||
) {
|
||||
if(state.showUserDefinedSettingStyle) {
|
||||
UserDefinedRoomNotificationSettingsView(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
onBackPressed = onBackPressed,
|
||||
)
|
||||
} else {
|
||||
RoomSpecificNotificationSettingsView(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
onShowGlobalNotifications = onShowGlobalNotifications,
|
||||
onBackPressed = onBackPressed,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoomSpecificNotificationSettingsView(
|
||||
state: RoomNotificationSettingsState,
|
||||
modifier: Modifier = Modifier,
|
||||
onShowGlobalNotifications: () -> Unit = {},
|
||||
onBackPressed: () -> Unit = {},
|
||||
) {
|
||||
Scaffold(
|
||||
|
|
@ -67,41 +95,84 @@ fun RoomNotificationSettingsView(
|
|||
.consumeWindowInsets(padding),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
val subtitle = when (state.defaultRoomNotificationMode) {
|
||||
RoomNotificationMode.ALL_MESSAGES -> stringResource(id = R.string.screen_room_notification_settings_mode_all_messages)
|
||||
RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> stringResource(id = R.string.screen_room_notification_settings_mode_mentions_and_keywords)
|
||||
RoomNotificationMode.MUTE -> stringResource(id = CommonStrings.common_mute)
|
||||
null -> ""
|
||||
}
|
||||
|
||||
|
||||
PreferenceCategory(title = stringResource(id = R.string.screen_room_notification_settings_custom_settings_title)) {
|
||||
PreferenceSwitch(
|
||||
isChecked = state.roomNotificationSettings?.isDefault.orTrue(),
|
||||
onCheckedChange = {
|
||||
state.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(it))
|
||||
},
|
||||
title = "Match default setting",
|
||||
subtitle = subtitle,
|
||||
enabled = state.roomNotificationSettings != null
|
||||
)
|
||||
|
||||
PreferenceText(
|
||||
title = stringResource(id = R.string.screen_room_notification_settings_allow_custom),
|
||||
subtitle = stringResource(id = R.string.screen_room_notification_settings_allow_custom_footnote),
|
||||
enabled = state.roomNotificationSettings != null && !state.roomNotificationSettings.isDefault,
|
||||
)
|
||||
|
||||
if (state.roomNotificationSettings != null) {
|
||||
val roomNotificationSettings = state.roomNotificationSettings.dataOrNull()
|
||||
PreferenceSwitch(
|
||||
isChecked = !state.displayIsDefault.orTrue(),
|
||||
onCheckedChange = {
|
||||
state.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(!it))
|
||||
},
|
||||
title = stringResource(id = R.string.screen_room_notification_settings_allow_custom),
|
||||
subtitle = stringResource(id = R.string.screen_room_notification_settings_allow_custom_footnote),
|
||||
enabled = roomNotificationSettings != null
|
||||
)
|
||||
if (state.displayIsDefault.orTrue()) {
|
||||
PreferenceCategory(title = stringResource(id = R.string.screen_room_notification_settings_default_setting_title)) {
|
||||
val text = buildAnnotatedStringWithStyledPart(
|
||||
R.string.screen_room_notification_settings_default_setting_footnote,
|
||||
R.string.screen_room_notification_settings_default_setting_footnote_content_link,
|
||||
color = Color.Unspecified,
|
||||
underline = false,
|
||||
bold = true,
|
||||
)
|
||||
ClickableText(
|
||||
text = text,
|
||||
onClick = {
|
||||
onShowGlobalNotifications()
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(start = 16.dp, bottom = 16.dp, end = 16.dp),
|
||||
style = ElementTheme.typography.fontBodyMdRegular
|
||||
.copy(
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
)
|
||||
if(state.defaultRoomNotificationMode != null){
|
||||
val defaultModeTitle = when (state.defaultRoomNotificationMode) {
|
||||
RoomNotificationMode.ALL_MESSAGES -> stringResource(id = R.string.screen_room_notification_settings_mode_all_messages)
|
||||
RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> {
|
||||
stringResource(id = R.string.screen_room_notification_settings_mode_mentions_and_keywords)
|
||||
}
|
||||
RoomNotificationMode.MUTE -> stringResource(id = CommonStrings.common_mute)
|
||||
}
|
||||
RoomNotificationSettingsOption(
|
||||
roomNotificationSettingsItem = RoomNotificationSettingsItem(state.defaultRoomNotificationMode, defaultModeTitle),
|
||||
isSelected = true,
|
||||
onOptionSelected = { },
|
||||
enabled = true
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
PreferenceCategory(title = stringResource(id = R.string.screen_room_notification_settings_custom_settings_title)) {
|
||||
RoomNotificationSettingsOptions(
|
||||
selected = state.roomNotificationSettings.mode,
|
||||
enabled = !state.roomNotificationSettings.isDefault,
|
||||
selected = state.displayNotificationMode,
|
||||
enabled = !state.displayIsDefault.orTrue(),
|
||||
onOptionSelected = {
|
||||
state.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(it.mode))
|
||||
},
|
||||
)
|
||||
},)
|
||||
}
|
||||
}
|
||||
|
||||
when (state.setNotificationSettingAction) {
|
||||
is Async.Loading -> {
|
||||
ProgressDialog()
|
||||
}
|
||||
is Async.Failure -> {
|
||||
ShowChangeNotificationSettingError(state, RoomNotificationSettingsEvents.ClearSetNotificationError)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
when (state.restoreDefaultAction) {
|
||||
is Async.Loading -> {
|
||||
ProgressDialog()
|
||||
}
|
||||
is Async.Failure -> {
|
||||
ShowChangeNotificationSettingError(state, RoomNotificationSettingsEvents.ClearRestoreDefaultError)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -124,26 +195,6 @@ private fun RoomNotificationSettingsTopBar(
|
|||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoomNotificationSettingsOptions(
|
||||
selected: RoomNotificationMode?,
|
||||
enabled: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onOptionSelected: (RoomNotificationSettingsItem) -> Unit = {},
|
||||
) {
|
||||
val items = roomNotificationSettingsItems()
|
||||
Column(modifier = modifier.selectableGroup()) {
|
||||
items.forEach { item ->
|
||||
RoomNotificationSettingsOption(
|
||||
roomNotificationSettingsItem = item,
|
||||
isSelected = selected == item.mode,
|
||||
onOptionSelected = onOptionSelected,
|
||||
enabled = enabled
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun RoomNotificationSettingsPreview(
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
)
|
||||
}
|
||||
|
|
@ -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 = { },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -24,6 +24,9 @@ import io.element.android.features.roomdetails.aMatrixRoom
|
|||
import io.element.android.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsEvents
|
||||
import io.element.android.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsPresenter
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
||||
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
|
||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
|
@ -31,12 +34,12 @@ import org.junit.Test
|
|||
class RoomNotificationSettingsPresenterTests {
|
||||
@Test
|
||||
fun `present - initial state is created from room info`() = runTest {
|
||||
val presenter = aNotificationPresenter
|
||||
val presenter = createRoomNotificationSettingsPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
Truth.assertThat(initialState.roomNotificationSettings).isNull()
|
||||
Truth.assertThat(initialState.roomNotificationSettings.dataOrNull()).isNull()
|
||||
Truth.assertThat(initialState.defaultRoomNotificationMode).isNull()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
|
|
@ -44,21 +47,80 @@ class RoomNotificationSettingsPresenterTests {
|
|||
|
||||
@Test
|
||||
fun `present - notification mode changed`() = runTest {
|
||||
val presenter = aNotificationPresenter
|
||||
val presenter = createRoomNotificationSettingsPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY))
|
||||
val updatedState = consumeItemsUntilPredicate {
|
||||
it.roomNotificationSettings?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
|
||||
it.roomNotificationSettings.dataOrNull()?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
|
||||
}.last()
|
||||
Truth.assertThat(updatedState.roomNotificationSettings?.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
|
||||
Truth.assertThat(updatedState.roomNotificationSettings.dataOrNull()?.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - observe notification mode changed`() = runTest {
|
||||
val notificationSettingsService = FakeNotificationSettingsService()
|
||||
val presenter = createRoomNotificationSettingsPresenter(notificationSettingsService)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
notificationSettingsService.setRoomNotificationMode(A_ROOM_ID, RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
|
||||
val updatedState = consumeItemsUntilPredicate {
|
||||
it.roomNotificationSettings.dataOrNull()?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
|
||||
}.last()
|
||||
Truth.assertThat(updatedState.roomNotificationSettings.dataOrNull()?.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - notification settings set custom failed`() = runTest {
|
||||
val notificationSettingsService = FakeNotificationSettingsService()
|
||||
notificationSettingsService.givenSetNotificationModeError(A_THROWABLE)
|
||||
val presenter = createRoomNotificationSettingsPresenter(notificationSettingsService)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(false))
|
||||
val failedState = consumeItemsUntilPredicate {
|
||||
it.setNotificationSettingAction.isFailure()
|
||||
}.last()
|
||||
|
||||
Truth.assertThat(failedState.roomNotificationSettings.dataOrNull()?.isDefault).isTrue()
|
||||
Truth.assertThat(failedState.pendingSetDefault).isNull()
|
||||
Truth.assertThat(failedState.setNotificationSettingAction.isFailure()).isTrue()
|
||||
|
||||
failedState.eventSink(RoomNotificationSettingsEvents.ClearSetNotificationError)
|
||||
|
||||
val errorClearedState = consumeItemsUntilPredicate {
|
||||
it.setNotificationSettingAction.isUninitialized()
|
||||
}.last()
|
||||
Truth.assertThat(errorClearedState.setNotificationSettingAction.isUninitialized()).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - notification settings set custom`() = runTest {
|
||||
val notificationSettingsService = FakeNotificationSettingsService()
|
||||
val presenter = createRoomNotificationSettingsPresenter(notificationSettingsService)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(false))
|
||||
val defaultState = consumeItemsUntilPredicate {
|
||||
it.roomNotificationSettings.dataOrNull()?.isDefault == false
|
||||
}.last()
|
||||
Truth.assertThat(defaultState.roomNotificationSettings.dataOrNull()?.isDefault).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - notification settings restore default`() = runTest {
|
||||
val presenter = aNotificationPresenter
|
||||
val presenter = createRoomNotificationSettingsPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
|
|
@ -66,17 +128,45 @@ class RoomNotificationSettingsPresenterTests {
|
|||
initialState.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY))
|
||||
initialState.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(true))
|
||||
val defaultState = consumeItemsUntilPredicate {
|
||||
it.roomNotificationSettings?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
|
||||
it.roomNotificationSettings.dataOrNull()?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
|
||||
}.last()
|
||||
Truth.assertThat(defaultState.roomNotificationSettings?.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
|
||||
Truth.assertThat(defaultState.roomNotificationSettings.dataOrNull()?.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
private val aNotificationPresenter: RoomNotificationSettingsPresenter get() {
|
||||
val room = aMatrixRoom()
|
||||
@Test
|
||||
fun `present - notification settings restore default failed`() = runTest {
|
||||
val notificationSettingsService = FakeNotificationSettingsService()
|
||||
notificationSettingsService.givenRestoreDefaultNotificationModeError(A_THROWABLE)
|
||||
val presenter = createRoomNotificationSettingsPresenter(notificationSettingsService)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY))
|
||||
initialState.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(true))
|
||||
val failedState = consumeItemsUntilPredicate {
|
||||
it.restoreDefaultAction.isFailure()
|
||||
}.last()
|
||||
Truth.assertThat(failedState.restoreDefaultAction.isFailure()).isTrue()
|
||||
failedState.eventSink(RoomNotificationSettingsEvents.ClearRestoreDefaultError)
|
||||
|
||||
val errorClearedState = consumeItemsUntilPredicate {
|
||||
it.restoreDefaultAction.isUninitialized()
|
||||
}.last()
|
||||
Truth.assertThat(errorClearedState.restoreDefaultAction.isUninitialized()).isTrue()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
private fun createRoomNotificationSettingsPresenter(
|
||||
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService()
|
||||
): RoomNotificationSettingsPresenter{
|
||||
val room = aMatrixRoom(notificationSettingsService = notificationSettingsService)
|
||||
return RoomNotificationSettingsPresenter(
|
||||
room = room,
|
||||
notificationSettingsService = room.notificationSettingsService
|
||||
notificationSettingsService = notificationSettingsService,
|
||||
showUserDefinedSettingStyle = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ google_firebase_bom = "com.google.firebase:firebase-bom:32.4.0"
|
|||
# AndroidX
|
||||
androidx_core = { module = "androidx.core:core", version.ref = "core" }
|
||||
androidx_corektx = { module = "androidx.core:core-ktx", version.ref = "core" }
|
||||
androidx_annotationjvm = { module = "androidx.annotation:annotation-jvm", version = "1.7.0" }
|
||||
androidx_datastore_preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
|
||||
androidx_datastore_datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
|
||||
androidx_exifinterface = "androidx.exifinterface:exifinterface:1.3.6"
|
||||
|
|
@ -132,7 +133,7 @@ test_hamcrest = "org.hamcrest:hamcrest:2.2"
|
|||
test_orchestrator = "androidx.test:orchestrator:1.4.2"
|
||||
test_turbine = "app.cash.turbine:turbine:1.0.0"
|
||||
test_truth = "com.google.truth:truth:1.1.5"
|
||||
test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.13"
|
||||
test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.14"
|
||||
test_robolectric = "org.robolectric:robolectric:4.10.3"
|
||||
test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = "appyx" }
|
||||
|
||||
|
|
@ -148,7 +149,7 @@ jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
|
|||
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
|
||||
molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" }
|
||||
timber = "com.jakewharton.timber:timber:5.0.1"
|
||||
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.63"
|
||||
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.65"
|
||||
matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" }
|
||||
matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" }
|
||||
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
|
||||
|
|
@ -164,6 +165,8 @@ statemachine = "com.freeletics.flowredux:compose:1.2.0"
|
|||
maplibre = "org.maplibre.gl:android-sdk:10.2.0"
|
||||
maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:2.0.1"
|
||||
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:2.0.1"
|
||||
opusencoder = "io.element.android:opusencoder:1.1.0"
|
||||
audiowaveform = "com.github.lincollincol:compose-audiowaveform:1.1.1"
|
||||
|
||||
# Analytics
|
||||
posthog = "com.posthog.android:posthog:2.0.3"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.core.hash
|
||||
|
||||
import java.security.MessageDigest
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Compute a Hash of a String, using md5 algorithm.
|
||||
*/
|
||||
fun String.md5() = try {
|
||||
val digest = MessageDigest.getInstance("md5")
|
||||
val locale = Locale.ROOT
|
||||
digest.update(toByteArray())
|
||||
digest.digest()
|
||||
.joinToString("") { String.format(locale, "%02X", it) }
|
||||
.lowercase(locale)
|
||||
} catch (exc: Exception) {
|
||||
// Should not happen, but just in case
|
||||
hashCode().toString()
|
||||
}
|
||||
|
|
@ -46,4 +46,6 @@ enum class AvatarSize(val dp: Dp) {
|
|||
EditRoomDetails(70.dp),
|
||||
|
||||
NotificationsOptIn(32.dp),
|
||||
|
||||
CustomRoomNotificationSetting(36.dp)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api.encryption
|
||||
|
||||
enum class BackupState {
|
||||
UNKNOWN,
|
||||
CREATING,
|
||||
ENABLING,
|
||||
RESUMING,
|
||||
ENABLED,
|
||||
DOWNLOADING,
|
||||
DISABLING,
|
||||
DISABLED;
|
||||
}
|
||||
|
|
@ -38,4 +38,5 @@ interface NotificationSettingsService {
|
|||
suspend fun setRoomMentionEnabled(enabled: Boolean): Result<Unit>
|
||||
suspend fun isCallEnabled(): Result<Boolean>
|
||||
suspend fun setCallEnabled(enabled: Boolean): Result<Unit>
|
||||
suspend fun getRoomsWithUserDefinedRules(): Result<List<String>>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ data class TracingFilterConfiguration(
|
|||
Target.MATRIX_SDK_HTTP_CLIENT to LogLevel.DEBUG,
|
||||
Target.MATRIX_SDK_SLIDING_SYNC to LogLevel.TRACE,
|
||||
Target.MATRIX_SDK_BASE_SLIDING_SYNC to LogLevel.TRACE,
|
||||
Target.MATRIX_SDK_UI_TIMELINE to LogLevel.TRACE,
|
||||
)
|
||||
|
||||
fun getLogLevel(target: Target): LogLevel {
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ class RustMatrixClient constructor(
|
|||
private val sessionDispatcher = dispatchers.io.limitedParallelism(64)
|
||||
private val sessionCoroutineScope = appCoroutineScope.childScope(dispatchers.main, "Session-${sessionId}")
|
||||
private val rustSyncService = RustSyncService(syncService, sessionCoroutineScope)
|
||||
private val verificationService = RustSessionVerificationService(rustSyncService)
|
||||
private val verificationService = RustSessionVerificationService(rustSyncService, sessionCoroutineScope)
|
||||
private val pushersService = RustPushersService(
|
||||
client = client,
|
||||
dispatchers = dispatchers,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.encryption
|
||||
|
||||
import io.element.android.libraries.matrix.api.encryption.BackupState
|
||||
import org.matrix.rustcomponents.sdk.BackupState as RustBackupState
|
||||
|
||||
class BackupStateMapper {
|
||||
fun map(backupState: RustBackupState): BackupState {
|
||||
return when (backupState) {
|
||||
RustBackupState.UNKNOWN -> BackupState.UNKNOWN
|
||||
RustBackupState.CREATING -> BackupState.CREATING
|
||||
RustBackupState.ENABLING -> BackupState.ENABLING
|
||||
RustBackupState.RESUMING -> BackupState.RESUMING
|
||||
RustBackupState.ENABLED -> BackupState.ENABLED
|
||||
RustBackupState.DOWNLOADING -> BackupState.DOWNLOADING
|
||||
RustBackupState.DISABLING -> BackupState.DISABLING
|
||||
RustBackupState.DISABLED -> BackupState.DISABLED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,8 @@ import kotlinx.coroutines.flow.asSharedFlow
|
|||
import kotlinx.coroutines.withContext
|
||||
import org.matrix.rustcomponents.sdk.NotificationSettings
|
||||
import org.matrix.rustcomponents.sdk.NotificationSettingsDelegate
|
||||
import org.matrix.rustcomponents.sdk.NotificationSettingsException
|
||||
import timber.log.Timber
|
||||
|
||||
class RustNotificationSettingsService(
|
||||
private val notificationSettings: NotificationSettings,
|
||||
|
|
@ -63,7 +65,13 @@ class RustNotificationSettingsService(
|
|||
isOneToOne: Boolean
|
||||
): Result<Unit> = withContext(dispatchers.io) {
|
||||
runCatching {
|
||||
notificationSettings.setDefaultRoomNotificationMode(isEncrypted, isOneToOne, mode.let(RoomNotificationSettingsMapper::mapMode))
|
||||
try {
|
||||
notificationSettings.setDefaultRoomNotificationMode(isEncrypted, isOneToOne, mode.let(RoomNotificationSettingsMapper::mapMode))
|
||||
} catch (exception: NotificationSettingsException.RuleNotFound) {
|
||||
// `setDefaultRoomNotificationMode` updates multiple rules including unstable rules (e.g. the polls push rules defined in the MSC3930)
|
||||
// since production home servers may not have these rules yet, we drop the RuleNotFound error
|
||||
Timber.w("Unable to find the rule: ${exception.ruleId}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -110,4 +118,9 @@ class RustNotificationSettingsService(
|
|||
notificationSettings.setCallEnabled(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getRoomsWithUserDefinedRules(): Result<List<String>> =
|
||||
runCatching {
|
||||
notificationSettings.getRoomsWithUserDefinedRules(enabled = true)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,10 +23,12 @@ import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatu
|
|||
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
|
||||
import io.element.android.libraries.matrix.impl.sync.RustSyncService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
import org.matrix.rustcomponents.sdk.SessionVerificationController
|
||||
import org.matrix.rustcomponents.sdk.SessionVerificationControllerDelegate
|
||||
import org.matrix.rustcomponents.sdk.SessionVerificationControllerInterface
|
||||
|
|
@ -35,6 +37,7 @@ import javax.inject.Inject
|
|||
|
||||
class RustSessionVerificationService @Inject constructor(
|
||||
private val syncService: RustSyncService,
|
||||
private val sessionCoroutineScope: CoroutineScope,
|
||||
) : SessionVerificationService, SessionVerificationControllerDelegate {
|
||||
|
||||
var verificationController: SessionVerificationControllerInterface? = null
|
||||
|
|
@ -44,7 +47,7 @@ class RustSessionVerificationService @Inject constructor(
|
|||
// If status was 'Unknown', move it to either 'Verified' or 'NotVerified'
|
||||
if (value != null) {
|
||||
value.setDelegate(this)
|
||||
updateVerificationStatus(value.isVerified())
|
||||
sessionCoroutineScope.launch { updateVerificationStatus(value.isVerified()) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,9 +43,11 @@ val A_USER_ID_10 = UserId("@walter:server.org")
|
|||
val A_SESSION_ID: SessionId = A_USER_ID
|
||||
val A_SESSION_ID_2: SessionId = A_USER_ID_2
|
||||
val A_SPACE_ID = SpaceId("!aSpaceId:domain")
|
||||
val A_SPACE_ID_2 = SpaceId("!aSpaceId2:domain")
|
||||
val A_ROOM_ID = RoomId("!aRoomId:domain")
|
||||
val A_ROOM_ID_2 = RoomId("!aRoomId2:domain")
|
||||
val A_THREAD_ID = ThreadId("\$aThreadId")
|
||||
val A_THREAD_ID_2 = ThreadId("\$aThreadId2")
|
||||
val AN_EVENT_ID = EventId("\$anEventId")
|
||||
val AN_EVENT_ID_2 = EventId("\$anEventId2")
|
||||
val A_TRANSACTION_ID = TransactionId("aTransactionId")
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue