Merge pull request #3023 from element-hq/feature/fga/update_rust_sdk_0.2.25

Feature/fga/update rust sdk 0.2.25
This commit is contained in:
ganfra 2024-06-13 14:07:10 +02:00 committed by GitHub
commit 4a68dd786c
184 changed files with 656 additions and 1047 deletions

View file

@ -41,6 +41,7 @@ import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.JoinedRoom import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.appnav.loggedin.LoggedInNode import io.element.android.appnav.loggedin.LoggedInNode
import io.element.android.appnav.loggedin.SendQueues
import io.element.android.appnav.room.RoomFlowNode import io.element.android.appnav.room.RoomFlowNode
import io.element.android.appnav.room.RoomNavigationTarget import io.element.android.appnav.room.RoomNavigationTarget
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
@ -102,6 +103,7 @@ class LoggedInFlowNode @AssistedInject constructor(
private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint, private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint,
private val shareEntryPoint: ShareEntryPoint, private val shareEntryPoint: ShareEntryPoint,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val sendingQueue: SendQueues,
snackbarDispatcher: SnackbarDispatcher, snackbarDispatcher: SnackbarDispatcher,
) : BaseFlowNode<LoggedInFlowNode.NavTarget>( ) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
@ -157,6 +159,11 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
) )
observeSyncStateAndNetworkStatus() observeSyncStateAndNetworkStatus()
setupSendingQueue()
}
private fun setupSendingQueue() {
sendingQueue.launchIn(lifecycleScope)
} }
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)

View file

@ -0,0 +1,60 @@
/*
* Copyright (c) 2024 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.appnav.loggedin
import androidx.annotation.VisibleForTesting
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import timber.log.Timber
import javax.inject.Inject
@VisibleForTesting
const val SEND_QUEUES_RETRY_DELAY_MILLIS = 1500L
@SingleIn(SessionScope::class)
class SendQueues @Inject constructor(
private val matrixClient: MatrixClient,
private val networkMonitor: NetworkMonitor,
) {
fun launchIn(coroutineScope: CoroutineScope) {
networkMonitor.connectivity
.onEach { networkStatus ->
matrixClient.setAllSendQueuesEnabled(enabled = networkStatus == NetworkStatus.Online)
}
.launchIn(coroutineScope)
matrixClient.sendQueueDisabledFlow()
.onEach { roomId: RoomId ->
Timber.d("Send queue disabled for room $roomId")
if (networkMonitor.connectivity.value == NetworkStatus.Online) {
delay(SEND_QUEUES_RETRY_DELAY_MILLIS)
matrixClient.getRoom(roomId)?.use { room ->
room.setSendQueueEnabled(enabled = true)
}
}
}
.launchIn(coroutineScope)
}
}

View file

@ -0,0 +1,113 @@
/*
* Copyright (c) 2024 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.appnav.loggedin
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class) class SendQueuesTest {
private val matrixClient = FakeMatrixClient()
private val room = FakeMatrixRoom()
private val networkMonitor = FakeNetworkMonitor()
private val sut = SendQueues(matrixClient, networkMonitor)
@Test
fun `test network status online and sending queue failed`() = runTest {
val sendQueueDisabledFlow = MutableSharedFlow<RoomId>(replay = 1)
val setAllSendQueuesEnabledLambda = lambdaRecorder { _: Boolean -> }
matrixClient.sendQueueDisabledFlow = sendQueueDisabledFlow
matrixClient.setAllSendQueuesEnabledLambda = setAllSendQueuesEnabledLambda
matrixClient.givenGetRoomResult(room.roomId, room)
val setRoomSendQueueEnabledLambda = lambdaRecorder { _: Boolean -> }
room.setSendQueueEnabledLambda = setRoomSendQueueEnabledLambda
sut.launchIn(backgroundScope)
sendQueueDisabledFlow.emit(room.roomId)
advanceTimeBy(SEND_QUEUES_RETRY_DELAY_MILLIS)
runCurrent()
assert(setAllSendQueuesEnabledLambda)
.isCalledOnce()
.with(value(true))
assert(setRoomSendQueueEnabledLambda)
.isCalledOnce()
.with(value(true))
}
@Test
fun `test network status offline and sending queue failed`() = runTest {
val sendQueueDisabledFlow = MutableSharedFlow<RoomId>(replay = 1)
val setAllSendQueuesEnabledLambda = lambdaRecorder { _: Boolean -> }
matrixClient.sendQueueDisabledFlow = sendQueueDisabledFlow
matrixClient.setAllSendQueuesEnabledLambda = setAllSendQueuesEnabledLambda
networkMonitor.connectivity.value = NetworkStatus.Offline
matrixClient.givenGetRoomResult(room.roomId, room)
val setRoomSendQueueEnabledLambda = lambdaRecorder { _: Boolean -> }
room.setSendQueueEnabledLambda = setRoomSendQueueEnabledLambda
sut.launchIn(backgroundScope)
sendQueueDisabledFlow.emit(room.roomId)
advanceTimeBy(SEND_QUEUES_RETRY_DELAY_MILLIS)
runCurrent()
assert(setAllSendQueuesEnabledLambda)
.isCalledOnce()
.with(value(false))
assert(setRoomSendQueueEnabledLambda)
.isNeverCalled()
}
@Test
fun `test network status getting offline and online`() = runTest {
val setEnableSendingQueueLambda = lambdaRecorder { _: Boolean -> }
matrixClient.setAllSendQueuesEnabledLambda = setEnableSendingQueueLambda
sut.launchIn(backgroundScope)
advanceTimeBy(SEND_QUEUES_RETRY_DELAY_MILLIS)
networkMonitor.connectivity.value = NetworkStatus.Offline
advanceTimeBy(SEND_QUEUES_RETRY_DELAY_MILLIS)
networkMonitor.connectivity.value = NetworkStatus.Online
advanceTimeBy(SEND_QUEUES_RETRY_DELAY_MILLIS)
assert(setEnableSendingQueueLambda)
.isCalledExactly(3)
.withSequence(
listOf(value(true)),
listOf(value(false)),
listOf(value(true)),
)
}
}

View file

@ -46,7 +46,6 @@ import io.element.android.features.messages.impl.timeline.TimelineState
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryPresenter import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryPresenter
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetPresenter import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetPresenter
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent
@ -107,7 +106,6 @@ class MessagesPresenter @AssistedInject constructor(
private val actionListPresenter: ActionListPresenter, private val actionListPresenter: ActionListPresenter,
private val customReactionPresenter: CustomReactionPresenter, private val customReactionPresenter: CustomReactionPresenter,
private val reactionSummaryPresenter: ReactionSummaryPresenter, private val reactionSummaryPresenter: ReactionSummaryPresenter,
private val retrySendMenuPresenter: RetrySendMenuPresenter,
private val readReceiptBottomSheetPresenter: ReadReceiptBottomSheetPresenter, private val readReceiptBottomSheetPresenter: ReadReceiptBottomSheetPresenter,
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
private val snackbarDispatcher: SnackbarDispatcher, private val snackbarDispatcher: SnackbarDispatcher,
@ -140,7 +138,6 @@ class MessagesPresenter @AssistedInject constructor(
val actionListState = actionListPresenter.present() val actionListState = actionListPresenter.present()
val customReactionState = customReactionPresenter.present() val customReactionState = customReactionPresenter.present()
val reactionSummaryState = reactionSummaryPresenter.present() val reactionSummaryState = reactionSummaryPresenter.present()
val retryState = retrySendMenuPresenter.present()
val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present() val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState() val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
@ -231,7 +228,6 @@ class MessagesPresenter @AssistedInject constructor(
actionListState = actionListState, actionListState = actionListState,
customReactionState = customReactionState, customReactionState = customReactionState,
reactionSummaryState = reactionSummaryState, reactionSummaryState = reactionSummaryState,
retrySendMenuState = retryState,
readReceiptBottomSheetState = readReceiptBottomSheetState, readReceiptBottomSheetState = readReceiptBottomSheetState,
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online, hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
@ -309,11 +305,9 @@ class MessagesPresenter @AssistedInject constructor(
} }
private suspend fun handleActionRedact(event: TimelineItem.Event) { private suspend fun handleActionRedact(event: TimelineItem.Event) {
if (event.failedToSend) { timelineController.invokeOnCurrentTimeline {
// If the message hasn't been sent yet, just cancel it redactEvent(eventId = event.eventId, transactionId = event.transactionId, reason = null)
event.transactionId?.let { room.cancelSend(it) } .onFailure { Timber.e(it) }
} else if (event.eventId != null) {
room.redactEvent(event.eventId)
} }
} }

View file

@ -23,7 +23,6 @@ import io.element.android.features.messages.impl.timeline.TimelineState
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
import io.element.android.features.messages.impl.typing.TypingNotificationState import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
@ -47,7 +46,6 @@ data class MessagesState(
val actionListState: ActionListState, val actionListState: ActionListState,
val customReactionState: CustomReactionState, val customReactionState: CustomReactionState,
val reactionSummaryState: ReactionSummaryState, val reactionSummaryState: ReactionSummaryState,
val retrySendMenuState: RetrySendMenuState,
val readReceiptBottomSheetState: ReadReceiptBottomSheetState, val readReceiptBottomSheetState: ReadReceiptBottomSheetState,
val hasNetworkConnection: Boolean, val hasNetworkConnection: Boolean,
val snackbarMessage: SnackbarMessage?, val snackbarMessage: SnackbarMessage?,

View file

@ -31,8 +31,6 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.aRetrySendMenuState
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.typing.aTypingNotificationState import io.element.android.features.messages.impl.typing.aTypingNotificationState
@ -110,7 +108,6 @@ fun aMessagesState(
// Render a focused event for an event with sender information displayed // Render a focused event for an event with sender information displayed
focusedEventIndex = 2, focusedEventIndex = 2,
), ),
retrySendMenuState: RetrySendMenuState = aRetrySendMenuState(),
readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(), readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(),
actionListState: ActionListState = anActionListState(), actionListState: ActionListState = anActionListState(),
customReactionState: CustomReactionState = aCustomReactionState(), customReactionState: CustomReactionState = aCustomReactionState(),
@ -132,7 +129,6 @@ fun aMessagesState(
voiceMessageComposerState = voiceMessageComposerState, voiceMessageComposerState = voiceMessageComposerState,
typingNotificationState = aTypingNotificationState(), typingNotificationState = aTypingNotificationState(),
timelineState = timelineState, timelineState = timelineState,
retrySendMenuState = retrySendMenuState,
readReceiptBottomSheetState = readReceiptBottomSheetState, readReceiptBottomSheetState = readReceiptBottomSheetState,
actionListState = actionListState, actionListState = actionListState,
customReactionState = customReactionState, customReactionState = customReactionState,

View file

@ -74,8 +74,6 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheet import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheet
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents
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.timeline.model.TimelineItem
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerEvents 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.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog
@ -103,7 +101,6 @@ import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import timber.log.Timber import timber.log.Timber
@ -212,11 +209,6 @@ fun MessagesView(
onMessageLongClick = ::onMessageLongClick, onMessageLongClick = ::onMessageLongClick,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onTimestampClick = { event ->
if (event.localSendState is LocalEventSendState.SendingFailed) {
state.retrySendMenuState.eventSink(RetrySendMenuEvents.EventSelected(event))
}
},
onReactionClick = ::onEmojiReactionClick, onReactionClick = ::onEmojiReactionClick,
onReactionLongClick = ::onEmojiReactionLongClick, onReactionLongClick = ::onEmojiReactionLongClick,
onMoreReactionsClick = ::onMoreReactionsClick, onMoreReactionsClick = ::onMoreReactionsClick,
@ -258,7 +250,6 @@ fun MessagesView(
) )
ReactionSummaryView(state = state.reactionSummaryState) ReactionSummaryView(state = state.reactionSummaryState)
RetrySendMessageMenu(state = state.retrySendMenuState)
ReadReceiptBottomSheet( ReadReceiptBottomSheet(
state = state.readReceiptBottomSheetState, state = state.readReceiptBottomSheetState,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
@ -319,7 +310,6 @@ private fun MessagesViewContent(
onMoreReactionsClick: (TimelineItem.Event) -> Unit, onMoreReactionsClick: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit, onReadReceiptClick: (TimelineItem.Event) -> Unit,
onMessageLongClick: (TimelineItem.Event) -> Unit, onMessageLongClick: (TimelineItem.Event) -> Unit,
onTimestampClick: (TimelineItem.Event) -> Unit,
onSendLocationClick: () -> Unit, onSendLocationClick: () -> Unit,
onCreatePollClick: () -> Unit, onCreatePollClick: () -> Unit,
onJoinCallClick: () -> Unit, onJoinCallClick: () -> Unit,
@ -387,7 +377,6 @@ private fun MessagesViewContent(
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onMessageClick = onMessageClick, onMessageClick = onMessageClick,
onMessageLongClick = onMessageLongClick, onMessageLongClick = onMessageLongClick,
onTimestampClick = onTimestampClick,
onSwipeToReply = onSwipeToReply, onSwipeToReply = onSwipeToReply,
onReactionClick = onReactionClick, onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick, onReactionLongClick = onReactionLongClick,

View file

@ -181,7 +181,7 @@ class ActionListPresenter @Inject constructor(
add(TimelineItemAction.Forward) add(TimelineItemAction.Forward)
} }
} }
if (timelineItem.isMine && timelineItem.isTextMessage) { if (timelineItem.isEditable) {
add(TimelineItemAction.Edit) add(TimelineItemAction.Edit)
} }
if (timelineItem.content.canBeCopied()) { if (timelineItem.content.canBeCopied()) {

View file

@ -83,7 +83,6 @@ fun TimelineView(
onLinkClick: (String) -> Unit, onLinkClick: (String) -> Unit,
onMessageClick: (TimelineItem.Event) -> Unit, onMessageClick: (TimelineItem.Event) -> Unit,
onMessageLongClick: (TimelineItem.Event) -> Unit, onMessageLongClick: (TimelineItem.Event) -> Unit,
onTimestampClick: (TimelineItem.Event) -> Unit,
onSwipeToReply: (TimelineItem.Event) -> Unit, onSwipeToReply: (TimelineItem.Event) -> Unit,
onReactionClick: (emoji: String, TimelineItem.Event) -> Unit, onReactionClick: (emoji: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (emoji: String, TimelineItem.Event) -> Unit, onReactionLongClick: (emoji: String, TimelineItem.Event) -> Unit,
@ -148,7 +147,6 @@ fun TimelineView(
onReactionLongClick = onReactionLongClick, onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick, onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick, onReadReceiptClick = onReadReceiptClick,
onTimestampClick = onTimestampClick,
eventSink = state.eventSink, eventSink = state.eventSink,
onSwipeToReply = onSwipeToReply, onSwipeToReply = onSwipeToReply,
onJoinCallClick = onJoinCallClick, onJoinCallClick = onJoinCallClick,
@ -245,8 +243,8 @@ private fun BoxScope.TimelineScrollHelper(
// Use inverse of canAutoScroll otherwise we might briefly see the before the scroll animation is triggered // Use inverse of canAutoScroll otherwise we might briefly see the before the scroll animation is triggered
isVisible = !canAutoScroll || forceJumpToBottomVisibility || !isLive, isVisible = !canAutoScroll || forceJumpToBottomVisibility || !isLive,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.padding(end = 24.dp, bottom = 12.dp), .padding(end = 24.dp, bottom = 12.dp),
onClick = { jumpToBottom() }, onClick = { jumpToBottom() },
) )
} }
@ -273,8 +271,8 @@ private fun JumpToBottomButton(
) { ) {
Icon( Icon(
modifier = Modifier modifier = Modifier
.size(24.dp) .size(24.dp)
.rotate(90f), .rotate(90f),
imageVector = CompoundIcons.ArrowRight(), imageVector = CompoundIcons.ArrowRight(),
contentDescription = stringResource(id = CommonStrings.a11y_jump_to_bottom) contentDescription = stringResource(id = CommonStrings.a11y_jump_to_bottom)
) )
@ -301,7 +299,6 @@ internal fun TimelineViewPreview(
onLinkClick = {}, onLinkClick = {},
onMessageClick = {}, onMessageClick = {},
onMessageLongClick = {}, onMessageLongClick = {},
onTimestampClick = {},
onSwipeToReply = {}, onSwipeToReply = {},
onReactionClick = { _, _ -> }, onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> }, onReactionLongClick = { _, _ -> },

View file

@ -45,6 +45,5 @@ internal fun ATimelineItemEventRow(
onMoreReactionsClick = {}, onMoreReactionsClick = {},
onReadReceiptClick = {}, onReadReceiptClick = {},
onSwipeToReply = {}, onSwipeToReply = {},
onTimestampClick = {},
eventSink = {}, eventSink = {},
) )

View file

@ -16,61 +16,34 @@
package io.element.android.features.messages.impl.timeline.components package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.isEdited import io.element.android.features.messages.impl.timeline.model.event.isEdited
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun TimelineEventTimestampView( fun TimelineEventTimestampView(
event: TimelineItem.Event, formattedTime: String,
onClick: () -> Unit, isMessageEdited: Boolean,
onLongClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val formattedTime = event.sentTime
val hasMessageSendingFailed = event.localSendState is LocalEventSendState.SendingFailed
val isMessageEdited = event.content.isEdited()
val tint = if (hasMessageSendingFailed) MaterialTheme.colorScheme.error else null
val clickModifier = if (hasMessageSendingFailed) {
Modifier.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
indication = rememberRipple(bounded = false),
interactionSource = remember { MutableInteractionSource() }
)
} else {
Modifier
}
Row( Row(
modifier = Modifier modifier = Modifier
.then(clickModifier)
// Add extra padding for touch target size
.padding(PaddingValues(start = TimelineEventTimestampViewDefaults.spacing)) .padding(PaddingValues(start = TimelineEventTimestampViewDefaults.spacing))
.then(modifier), .then(modifier),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -79,35 +52,22 @@ fun TimelineEventTimestampView(
Text( Text(
stringResource(CommonStrings.common_edited_suffix), stringResource(CommonStrings.common_edited_suffix),
style = ElementTheme.typography.fontBodyXsRegular, style = ElementTheme.typography.fontBodyXsRegular,
color = tint ?: MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
} }
Text( Text(
formattedTime, formattedTime,
style = ElementTheme.typography.fontBodyXsRegular, style = ElementTheme.typography.fontBodyXsRegular,
color = tint ?: MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
) )
if (hasMessageSendingFailed && tint != null) {
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = CompoundIcons.Error(),
contentDescription = stringResource(id = CommonStrings.common_sending_failed),
tint = tint,
modifier = Modifier.size(15.dp, 18.dp),
)
}
} }
} }
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun TimelineEventTimestampViewPreview(@PreviewParameter(TimelineItemEventForTimestampViewProvider::class) event: TimelineItem.Event) = ElementPreview { internal fun TimelineEventTimestampViewPreview(@PreviewParameter(TimelineItemEventForTimestampViewProvider::class) event: TimelineItem.Event) = ElementPreview {
TimelineEventTimestampView( TimelineEventTimestampView(formattedTime = event.sentTime, isMessageEdited = event.content.isEdited())
event = event,
onClick = {},
onLongClick = {},
)
} }
object TimelineEventTimestampViewDefaults { object TimelineEventTimestampViewDefaults {

View file

@ -92,6 +92,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.canBeRepliedTo import io.element.android.features.messages.impl.timeline.model.event.canBeRepliedTo
import io.element.android.features.messages.impl.timeline.model.event.isEdited
import io.element.android.features.messages.impl.timeline.model.eventId import io.element.android.features.messages.impl.timeline.model.eventId
import io.element.android.features.messages.impl.timeline.model.metadata import io.element.android.features.messages.impl.timeline.model.metadata
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
@ -130,7 +131,6 @@ fun TimelineItemEventRow(
onLinkClick: (String) -> Unit, onLinkClick: (String) -> Unit,
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
inReplyToClick: (EventId) -> Unit, inReplyToClick: (EventId) -> Unit,
onTimestampClick: (TimelineItem.Event) -> Unit,
onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit, onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,
onReactionLongClick: (emoji: String, eventId: TimelineItem.Event) -> Unit, onReactionLongClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,
onMoreReactionsClick: (eventId: TimelineItem.Event) -> Unit, onMoreReactionsClick: (eventId: TimelineItem.Event) -> Unit,
@ -190,7 +190,6 @@ fun TimelineItemEventRow(
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
onTimestampClick = onTimestampClick,
inReplyToClick = ::inReplyToClick, inReplyToClick = ::inReplyToClick,
onUserDataClick = ::onUserDataClick, onUserDataClick = ::onUserDataClick,
onReactionClick = { emoji -> onReactionClick(emoji, event) }, onReactionClick = { emoji -> onReactionClick(emoji, event) },
@ -209,7 +208,6 @@ fun TimelineItemEventRow(
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
onTimestampClick = onTimestampClick,
inReplyToClick = ::inReplyToClick, inReplyToClick = ::inReplyToClick,
onUserDataClick = ::onUserDataClick, onUserDataClick = ::onUserDataClick,
onReactionClick = { emoji -> onReactionClick(emoji, event) }, onReactionClick = { emoji -> onReactionClick(emoji, event) },
@ -265,7 +263,6 @@ private fun TimelineItemEventRowContent(
interactionSource: MutableInteractionSource, interactionSource: MutableInteractionSource,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
onTimestampClick: (TimelineItem.Event) -> Unit,
inReplyToClick: () -> Unit, inReplyToClick: () -> Unit,
onUserDataClick: () -> Unit, onUserDataClick: () -> Unit,
onReactionClick: (emoji: String) -> Unit, onReactionClick: (emoji: String) -> Unit,
@ -337,9 +334,6 @@ private fun TimelineItemEventRowContent(
event = event, event = event,
onMessageLongClick = onLongClick, onMessageLongClick = onLongClick,
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,
onTimestampClick = {
onTimestampClick(event)
},
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
eventSink = eventSink, eventSink = eventSink,
) )
@ -419,7 +413,6 @@ private fun MessageEventBubbleContent(
event: TimelineItem.Event, event: TimelineItem.Event,
onMessageLongClick: () -> Unit, onMessageLongClick: () -> Unit,
inReplyToClick: () -> Unit, inReplyToClick: () -> Unit,
onTimestampClick: () -> Unit,
onLinkClick: (String) -> Unit, onLinkClick: (String) -> Unit,
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
@SuppressLint("ModifierParameter") @SuppressLint("ModifierParameter")
@ -467,9 +460,8 @@ private fun MessageEventBubbleContent(
Box(modifier, contentAlignment = Alignment.Center) { Box(modifier, contentAlignment = Alignment.Center) {
content {} content {}
TimelineEventTimestampView( TimelineEventTimestampView(
event = event, formattedTime = event.sentTime,
onClick = onTimestampClick, isMessageEdited = event.content.isEdited(),
onLongClick = ::onTimestampLongClick,
modifier = Modifier modifier = Modifier
// Outer padding // Outer padding
.padding(horizontal = 4.dp, vertical = 4.dp) .padding(horizontal = 4.dp, vertical = 4.dp)
@ -489,9 +481,8 @@ private fun MessageEventBubbleContent(
content = { content(this::onContentLayoutChange) }, content = { content(this::onContentLayoutChange) },
overlay = { overlay = {
TimelineEventTimestampView( TimelineEventTimestampView(
event = event, formattedTime = event.sentTime,
onClick = onTimestampClick, isMessageEdited = event.content.isEdited(),
onLongClick = ::onTimestampLongClick,
modifier = Modifier modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp) .padding(horizontal = 8.dp, vertical = 4.dp)
) )
@ -501,9 +492,8 @@ private fun MessageEventBubbleContent(
Column(modifier) { Column(modifier) {
content {} content {}
TimelineEventTimestampView( TimelineEventTimestampView(
event = event, formattedTime = event.sentTime,
onClick = onTimestampClick, isMessageEdited = event.content.isEdited(),
onLongClick = ::onTimestampLongClick,
modifier = Modifier modifier = Modifier
.align(Alignment.End) .align(Alignment.End)
.padding(horizontal = 8.dp, vertical = 4.dp) .padding(horizontal = 8.dp, vertical = 4.dp)

View file

@ -49,7 +49,6 @@ fun TimelineItemGroupedEventsRow(
inReplyToClick: (EventId) -> Unit, inReplyToClick: (EventId) -> Unit,
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit, onLinkClick: (String) -> Unit,
onTimestampClick: (TimelineItem.Event) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit, onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit, onMoreReactionsClick: (TimelineItem.Event) -> Unit,
@ -76,7 +75,6 @@ fun TimelineItemGroupedEventsRow(
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onTimestampClick = onTimestampClick,
onReactionClick = onReactionClick, onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick, onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick, onMoreReactionsClick = onMoreReactionsClick,
@ -100,7 +98,6 @@ private fun TimelineItemGroupedEventsRowContent(
inReplyToClick: (EventId) -> Unit, inReplyToClick: (EventId) -> Unit,
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit, onLinkClick: (String) -> Unit,
onTimestampClick: (TimelineItem.Event) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit, onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit, onMoreReactionsClick: (TimelineItem.Event) -> Unit,
@ -133,7 +130,6 @@ private fun TimelineItemGroupedEventsRowContent(
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onTimestampClick = onTimestampClick,
onReactionClick = onReactionClick, onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick, onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick, onMoreReactionsClick = onMoreReactionsClick,
@ -175,7 +171,6 @@ internal fun TimelineItemGroupedEventsRowContentExpandedPreview() = ElementPrevi
inReplyToClick = {}, inReplyToClick = {},
onUserDataClick = {}, onUserDataClick = {},
onLinkClick = {}, onLinkClick = {},
onTimestampClick = {},
onReactionClick = { _, _ -> }, onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> }, onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {}, onMoreReactionsClick = {},
@ -200,7 +195,6 @@ internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPrevi
inReplyToClick = {}, inReplyToClick = {},
onUserDataClick = {}, onUserDataClick = {},
onLinkClick = {}, onLinkClick = {},
onTimestampClick = {},
onReactionClick = { _, _ -> }, onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> }, onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {}, onMoreReactionsClick = {},

View file

@ -54,7 +54,6 @@ internal fun TimelineItemRow(
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit, onMoreReactionsClick: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit, onReadReceiptClick: (TimelineItem.Event) -> Unit,
onTimestampClick: (TimelineItem.Event) -> Unit,
onSwipeToReply: (TimelineItem.Event) -> Unit, onSwipeToReply: (TimelineItem.Event) -> Unit,
onJoinCallClick: () -> Unit, onJoinCallClick: () -> Unit,
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
@ -118,7 +117,6 @@ internal fun TimelineItemRow(
onReactionLongClick = onReactionLongClick, onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick, onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick, onReadReceiptClick = onReadReceiptClick,
onTimestampClick = onTimestampClick,
onSwipeToReply = { onSwipeToReply(timelineItem) }, onSwipeToReply = { onSwipeToReply(timelineItem) },
eventSink = eventSink, eventSink = eventSink,
) )
@ -137,7 +135,6 @@ internal fun TimelineItemRow(
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onTimestampClick = onTimestampClick,
onReactionClick = onReactionClick, onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick, onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick, onMoreReactionsClick = onMoreReactionsClick,

View file

@ -81,7 +81,8 @@ fun TimelineItemReadReceiptView(
} }
} else { } else {
when (state.sendState) { when (state.sendState) {
LocalEventSendState.NotSentYet -> { is LocalEventSendState.SendingFailed,
is LocalEventSendState.NotSentYet -> {
ReadReceiptsRow(modifier) { ReadReceiptsRow(modifier) {
Icon( Icon(
modifier = Modifier.padding(2.dp), modifier = Modifier.padding(2.dp),
@ -91,10 +92,6 @@ fun TimelineItemReadReceiptView(
) )
} }
} }
LocalEventSendState.Canceled -> Unit
is LocalEventSendState.SendingFailed -> {
// Error? The timestamp is already displayed in red
}
null, null,
is LocalEventSendState.Sent -> { is LocalEventSendState.Sent -> {
if (state.isLastOutgoingMessage) { if (state.isLastOutgoingMessage) {

View file

@ -123,7 +123,6 @@ private fun ReadReceiptBottomSheetContent(
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun ReadReceiptBottomSheetPreview(@PreviewParameter(ReadReceiptBottomSheetStateProvider::class) state: ReadReceiptBottomSheetState) = ElementPreview { internal fun ReadReceiptBottomSheetPreview(@PreviewParameter(ReadReceiptBottomSheetStateProvider::class) state: ReadReceiptBottomSheetState) = ElementPreview {
// TODO restore RetrySendMessageMenuBottomSheet once the issue with bottom sheet not being previewable is fixed
Column { Column {
ReadReceiptBottomSheetContent( ReadReceiptBottomSheetContent(
state = state, state = state,

View file

@ -1,26 +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.timeline.components.retrysendmenu
import io.element.android.features.messages.impl.timeline.model.TimelineItem
sealed interface RetrySendMenuEvents {
data class EventSelected(val event: TimelineItem.Event) : RetrySendMenuEvents
data object Retry : RetrySendMenuEvents
data object Remove : RetrySendMenuEvents
data object Dismiss : RetrySendMenuEvents
}

View file

@ -1,71 +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.timeline.components.retrysendmenu
import androidx.compose.runtime.Composable
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 io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.room.MatrixRoom
import kotlinx.coroutines.launch
import javax.inject.Inject
class RetrySendMenuPresenter @Inject constructor(
private val room: MatrixRoom,
) : Presenter<RetrySendMenuState> {
@Composable
override fun present(): RetrySendMenuState {
val coroutineScope = rememberCoroutineScope()
var selectedEvent: TimelineItem.Event? by remember { mutableStateOf(null) }
fun handleEvent(event: RetrySendMenuEvents) {
when (event) {
is RetrySendMenuEvents.EventSelected -> {
selectedEvent = event.event
}
RetrySendMenuEvents.Retry -> {
coroutineScope.launch {
selectedEvent?.transactionId?.let { transactionId ->
room.retrySendMessage(transactionId)
}
selectedEvent = null
}
}
RetrySendMenuEvents.Remove -> {
coroutineScope.launch {
selectedEvent?.transactionId?.let { transactionId ->
room.cancelSend(transactionId)
}
selectedEvent = null
}
}
RetrySendMenuEvents.Dismiss -> {
selectedEvent = null
}
}
}
return RetrySendMenuState(
selectedEvent = selectedEvent,
eventSink = { handleEvent(it) },
)
}
}

View file

@ -1,26 +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.timeline.components.retrysendmenu
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.timeline.model.TimelineItem
@Immutable
data class RetrySendMenuState(
val selectedEvent: TimelineItem.Event?,
val eventSink: (RetrySendMenuEvents) -> Unit,
)

View file

@ -1,36 +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.timeline.components.retrysendmenu
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.model.TimelineItem
class RetrySendMenuStateProvider : PreviewParameterProvider<RetrySendMenuState> {
override val values: Sequence<RetrySendMenuState> = sequenceOf(
aRetrySendMenuState(),
aRetrySendMenuState(event = aTimelineItemEvent()),
)
}
fun aRetrySendMenuState(
event: TimelineItem.Event? = null,
eventSink: (RetrySendMenuEvents) -> Unit = {},
) = RetrySendMenuState(
selectedEvent = event,
eventSink = eventSink,
)

View file

@ -1,159 +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.timeline.components.retrysendmenu
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SheetState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.R
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
@Composable
internal fun RetrySendMessageMenu(
state: RetrySendMenuState,
modifier: Modifier = Modifier,
) {
val isVisible = state.selectedEvent != null
fun onDismiss() {
state.eventSink(RetrySendMenuEvents.Dismiss)
}
fun onRetry() {
state.eventSink(RetrySendMenuEvents.Retry)
}
fun onRemove() {
state.eventSink(RetrySendMenuEvents.Remove)
}
RetrySendMessageMenuBottomSheet(
modifier = modifier,
isVisible = isVisible,
onRetry = ::onRetry,
onRemove = ::onRemove,
onDismiss = ::onDismiss
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RetrySendMessageMenuBottomSheet(
isVisible: Boolean,
onRetry: () -> Unit,
onRemove: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val sheetState = rememberModalBottomSheetState()
val coroutineScope = rememberCoroutineScope()
if (isVisible) {
ModalBottomSheet(
modifier = modifier,
// modifier = modifier.navigationBarsPadding() - FIXME after https://issuetracker.google.com/issues/275849044
// .imePadding()
sheetState = sheetState,
onDismissRequest = {
coroutineScope.launch {
sheetState.hide()
onDismiss()
}
}
) {
RetrySendMenuContents(
onRetry = onRetry,
onRemove = onRemove,
)
// FIXME remove after https://issuetracker.google.com/issues/275849044
Spacer(modifier = Modifier.height(32.dp))
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ColumnScope.RetrySendMenuContents(
onRetry: () -> Unit,
onRemove: () -> Unit,
sheetState: SheetState = rememberModalBottomSheetState(),
) {
val coroutineScope = rememberCoroutineScope()
ListItem(headlineContent = {
Text(
text = stringResource(R.string.screen_room_retry_send_menu_title),
style = ElementTheme.typography.fontBodyLgMedium,
)
})
ListItem(
headlineContent = {
Text(
text = stringResource(R.string.screen_room_retry_send_menu_send_again_action),
style = ElementTheme.typography.fontBodyLgRegular,
)
},
modifier = Modifier.clickable {
coroutineScope.launch {
sheetState.hide()
onRetry()
}
}
)
ListItem(
headlineContent = {
Text(
text = stringResource(CommonStrings.action_remove),
style = ElementTheme.typography.fontBodyLgRegular,
)
},
colors = ListItemDefaults.colors(headlineColor = MaterialTheme.colorScheme.error),
modifier = Modifier.clickable {
coroutineScope.launch {
sheetState.hide()
onRemove()
}
}
)
}
@PreviewsDayNight
@Composable
internal fun RetrySendMessageMenuPreview(@PreviewParameter(RetrySendMenuStateProvider::class) state: RetrySendMenuState) = ElementPreview {
RetrySendMessageMenu(
state = state,
)
}

View file

@ -38,7 +38,6 @@ import io.element.android.features.messages.impl.timeline.components.customreact
import io.element.android.features.messages.impl.timeline.components.customreaction.FakeEmojibaseProvider import io.element.android.features.messages.impl.timeline.components.customreaction.FakeEmojibaseProvider
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryPresenter import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryPresenter
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetPresenter import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetPresenter
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
@ -64,19 +63,18 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatch
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2 import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.A_TRANSACTION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
@ -445,41 +443,25 @@ class MessagesPresenterTest {
@Test @Test
fun `present - handle action redact`() = runTest { fun `present - handle action redact`() = runTest {
val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
val matrixRoom = FakeMatrixRoom()
val presenter = createMessagesPresenter(matrixRoom = matrixRoom, coroutineDispatchers = coroutineDispatchers)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Redact, aMessageEvent()))
assertThat(matrixRoom.redactEventEventIdParam).isEqualTo(AN_EVENT_ID)
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
}
}
@Test val liveTimeline = FakeTimeline()
fun `present - handle action redact message in error, in this case the message is just cancelled`() = runTest { val matrixRoom = FakeMatrixRoom(liveTimeline = liveTimeline)
val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
val matrixRoom = FakeMatrixRoom() val redactEventLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String? -> Result.success(true) }
liveTimeline.redactEventLambda = redactEventLambda
val presenter = createMessagesPresenter(matrixRoom = matrixRoom, coroutineDispatchers = coroutineDispatchers) val presenter = createMessagesPresenter(matrixRoom = matrixRoom, coroutineDispatchers = coroutineDispatchers)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke( val messageEvent = aMessageEvent()
MessagesEvents.HandleAction( initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Redact, messageEvent))
action = TimelineItemAction.Redact,
event = aMessageEvent(
transactionId = A_TRANSACTION_ID,
sendState = LocalEventSendState.SendingFailed("Failed to send message")
)
)
)
assertThat(matrixRoom.cancelSendCount).isEqualTo(1)
assertThat(matrixRoom.redactEventEventIdParam).isNull()
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
assert(redactEventLambda)
.isCalledOnce()
.with(value(messageEvent.eventId), value(messageEvent.transactionId), value(null))
} }
} }
@ -839,7 +821,6 @@ class MessagesPresenterTest {
val readReceiptBottomSheetPresenter = ReadReceiptBottomSheetPresenter() val readReceiptBottomSheetPresenter = ReadReceiptBottomSheetPresenter()
val customReactionPresenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider()) val customReactionPresenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider())
val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom) val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom)
val retrySendMenuPresenter = RetrySendMenuPresenter(room = matrixRoom)
return MessagesPresenter( return MessagesPresenter(
room = matrixRoom, room = matrixRoom,
composerPresenter = messageComposerPresenter, composerPresenter = messageComposerPresenter,
@ -849,7 +830,6 @@ class MessagesPresenterTest {
actionListPresenter = actionListPresenter, actionListPresenter = actionListPresenter,
customReactionPresenter = customReactionPresenter, customReactionPresenter = customReactionPresenter,
reactionSummaryPresenter = reactionSummaryPresenter, reactionSummaryPresenter = reactionSummaryPresenter,
retrySendMenuPresenter = retrySendMenuPresenter,
readReceiptBottomSheetPresenter = readReceiptBottomSheetPresenter, readReceiptBottomSheetPresenter = readReceiptBottomSheetPresenter,
networkMonitor = FakeNetworkMonitor(), networkMonitor = FakeNetworkMonitor(),
snackbarDispatcher = SnackbarDispatcher(), snackbarDispatcher = SnackbarDispatcher(),

View file

@ -52,8 +52,6 @@ import io.element.android.features.messages.impl.timeline.components.customreact
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvents import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvents
import io.element.android.features.messages.impl.timeline.components.receipt.aReadReceiptData import io.element.android.features.messages.impl.timeline.components.receipt.aReadReceiptData
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuEvents
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.aRetrySendMenuState
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
@ -74,6 +72,7 @@ import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class MessagesViewTest { class MessagesViewTest {
@ -145,22 +144,6 @@ class MessagesViewTest {
callback.assertSuccess() callback.assertSuccess()
} }
@Test
fun `clicking on an Event timestamp in error emits the expected Event`() {
val eventsRecorder = EventsRecorder<RetrySendMenuEvents>()
val state = aMessagesState(
retrySendMenuState = aRetrySendMenuState(
eventSink = eventsRecorder
),
)
val timelineItem = state.timelineState.timelineItems[1] as TimelineItem.Event
rule.setMessagesView(
state = state,
)
rule.onAllNodesWithText(timelineItem.sentTime)[1].performClick()
eventsRecorder.assertSingle(RetrySendMenuEvents.EventSelected(timelineItem))
}
@Test @Test
fun `long clicking on an Event emits the expected Event userHasPermissionToSendMessage`() { fun `long clicking on an Event emits the expected Event userHasPermissionToSendMessage`() {
`long clicking on an Event emits the expected Event`(userHasPermissionToSendMessage = true) `long clicking on an Event emits the expected Event`(userHasPermissionToSendMessage = true)
@ -313,6 +296,7 @@ class MessagesViewTest {
} }
@Test @Test
@Config(qualifiers = "h1024dp")
fun `clicking on the sender of an Event invoke expected callback`() { fun `clicking on the sender of an Event invoke expected callback`() {
val eventsRecorder = EventsRecorder<MessagesEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<MessagesEvents>(expectEvents = false)
val state = aMessagesState( val state = aMessagesState(

View file

@ -62,7 +62,7 @@ class ActionListPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
val messageEvent = aMessageEvent(isMine = true, content = TimelineItemRedactedContent) val messageEvent = aMessageEvent(isMine = true, isEditable = false, content = TimelineItemRedactedContent)
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = messageEvent, event = messageEvent,
@ -96,7 +96,11 @@ class ActionListPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
val messageEvent = aMessageEvent(isMine = false, content = TimelineItemRedactedContent) val messageEvent = aMessageEvent(
isMine = false,
isEditable = false,
content = TimelineItemRedactedContent
)
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = messageEvent, event = messageEvent,
@ -132,6 +136,7 @@ class ActionListPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
val messageEvent = aMessageEvent( val messageEvent = aMessageEvent(
isMine = false, isMine = false,
isEditable = false,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null) content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
) )
initialState.eventSink.invoke( initialState.eventSink.invoke(
@ -174,6 +179,7 @@ class ActionListPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
val messageEvent = aMessageEvent( val messageEvent = aMessageEvent(
isMine = false, isMine = false,
isEditable = false,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null) content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
) )
initialState.eventSink.invoke( initialState.eventSink.invoke(
@ -215,6 +221,7 @@ class ActionListPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
val messageEvent = aMessageEvent( val messageEvent = aMessageEvent(
isMine = false, isMine = false,
isEditable = false,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null) content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
) )
initialState.eventSink.invoke( initialState.eventSink.invoke(
@ -256,6 +263,7 @@ class ActionListPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
val messageEvent = aMessageEvent( val messageEvent = aMessageEvent(
isMine = false, isMine = false,
isEditable = false,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null) content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
) )
initialState.eventSink.invoke( initialState.eventSink.invoke(
@ -382,6 +390,7 @@ class ActionListPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
val messageEvent = aMessageEvent( val messageEvent = aMessageEvent(
isMine = true, isMine = true,
isEditable = false,
content = aTimelineItemImageContent(), content = aTimelineItemImageContent(),
) )
initialState.eventSink.invoke( initialState.eventSink.invoke(

View file

@ -106,7 +106,6 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setTimel
onLinkClick: (String) -> Unit = EnsureNeverCalledWithParam(), onLinkClick: (String) -> Unit = EnsureNeverCalledWithParam(),
onMessageClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), onMessageClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
onMessageLongClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), onMessageLongClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
onTimestampClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
onSwipeToReply: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), onSwipeToReply: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
onReactionClick: (emoji: String, TimelineItem.Event) -> Unit = EnsureNeverCalledWithTwoParams(), onReactionClick: (emoji: String, TimelineItem.Event) -> Unit = EnsureNeverCalledWithTwoParams(),
onReactionLongClick: (emoji: String, TimelineItem.Event) -> Unit = EnsureNeverCalledWithTwoParams(), onReactionLongClick: (emoji: String, TimelineItem.Event) -> Unit = EnsureNeverCalledWithTwoParams(),
@ -123,7 +122,6 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setTimel
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onMessageClick = onMessageClick, onMessageClick = onMessageClick,
onMessageLongClick = onMessageLongClick, onMessageLongClick = onMessageLongClick,
onTimestampClick = onTimestampClick,
onSwipeToReply = onSwipeToReply, onSwipeToReply = onSwipeToReply,
onReactionClick = onReactionClick, onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick, onReactionLongClick = onReactionLongClick,

View file

@ -1,163 +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.timeline.components.retrysendmenu
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.libraries.matrix.test.A_TRANSACTION_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class RetrySendMenuPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
private val room = FakeMatrixRoom()
private val presenter = RetrySendMenuPresenter(room)
@Test
fun `present - handle event selected`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val selectedEvent = aTimelineItemEvent()
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
assertThat(awaitItem().selectedEvent).isSameInstanceAs(selectedEvent)
}
}
@Test
fun `present - handle dismiss`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val selectedEvent = aTimelineItemEvent()
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1)
initialState.eventSink(RetrySendMenuEvents.Dismiss)
assertThat(room.cancelSendCount).isEqualTo(0)
assertThat(room.retrySendMessageCount).isEqualTo(0)
assertThat(awaitItem().selectedEvent).isNull()
}
}
@Test
fun `present - handle resend with transactionId`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1)
initialState.eventSink(RetrySendMenuEvents.Retry)
assertThat(room.cancelSendCount).isEqualTo(0)
assertThat(room.retrySendMessageCount).isEqualTo(1)
assertThat(awaitItem().selectedEvent).isNull()
}
}
@Test
fun `present - handle resend without transactionId`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val selectedEvent = aTimelineItemEvent(transactionId = null)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1)
initialState.eventSink(RetrySendMenuEvents.Retry)
assertThat(room.cancelSendCount).isEqualTo(0)
assertThat(room.retrySendMessageCount).isEqualTo(0)
assertThat(awaitItem().selectedEvent).isNull()
}
}
@Test
fun `present - handle resend with error`() = runTest {
room.givenRetrySendMessageResult(Result.failure(IllegalStateException("An error")))
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1)
initialState.eventSink(RetrySendMenuEvents.Retry)
assertThat(room.cancelSendCount).isEqualTo(0)
assertThat(room.retrySendMessageCount).isEqualTo(1)
assertThat(awaitItem().selectedEvent).isNull()
}
}
@Test
fun `present - handle remove failed message with transactionId`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1)
initialState.eventSink(RetrySendMenuEvents.Remove)
assertThat(room.cancelSendCount).isEqualTo(1)
assertThat(room.retrySendMessageCount).isEqualTo(0)
assertThat(awaitItem().selectedEvent).isNull()
}
}
@Test
fun `present - handle remove failed message without transactionId`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val selectedEvent = aTimelineItemEvent(transactionId = null)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1)
initialState.eventSink(RetrySendMenuEvents.Remove)
assertThat(room.cancelSendCount).isEqualTo(0)
assertThat(room.retrySendMessageCount).isEqualTo(0)
assertThat(awaitItem().selectedEvent).isNull()
}
}
@Test
fun `present - handle remove failed message with error`() = runTest {
room.givenRetrySendMessageResult(Result.failure(IllegalStateException("An error")))
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1)
initialState.eventSink(RetrySendMenuEvents.Remove)
assertThat(room.cancelSendCount).isEqualTo(1)
assertThat(room.retrySendMessageCount).isEqualTo(0)
assertThat(awaitItem().selectedEvent).isNull()
}
}
}

View file

@ -1,90 +0,0 @@
/*
* Copyright (c) 2024 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.retrysendmenu
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.pressBackKey
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
class RetrySendMessageMenuTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `dismiss the bottom sheet emits the expected event`() {
val eventsRecorder = EventsRecorder<RetrySendMenuEvents>()
rule.setRetrySendMessageMenu(
aRetrySendMenuState(
event = aTimelineItemEvent(),
eventSink = eventsRecorder
),
)
rule.pressBackKey()
// Cannot test this for now.
// eventsRecorder.assertSingle(RetrySendMenuEvents.Dismiss)
}
@Config(qualifiers = "h1024dp")
@Test
fun `retry to send the event emits the expected event`() {
val eventsRecorder = EventsRecorder<RetrySendMenuEvents>()
rule.setRetrySendMessageMenu(
aRetrySendMenuState(
event = aTimelineItemEvent(),
eventSink = eventsRecorder
),
)
rule.clickOn(R.string.screen_room_retry_send_menu_send_again_action)
eventsRecorder.assertSingle(RetrySendMenuEvents.Retry)
}
@Config(qualifiers = "h1024dp")
@Test
fun `remove the event emits the expected event`() {
val eventsRecorder = EventsRecorder<RetrySendMenuEvents>()
rule.setRetrySendMessageMenu(
aRetrySendMenuState(
event = aTimelineItemEvent(),
eventSink = eventsRecorder
),
)
rule.clickOn(CommonStrings.action_remove)
eventsRecorder.assertSingle(RetrySendMenuEvents.Remove)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRetrySendMessageMenu(
state: RetrySendMenuState,
) {
setContent {
RetrySendMessageMenu(
state = state,
)
}
}

View file

@ -0,0 +1,41 @@
/*
* Copyright (c) 2024 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.migration.impl.migrations
import com.squareup.anvil.annotations.ContributesMultibinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.sessionstorage.api.SessionStore
import java.io.File
import javax.inject.Inject
@ContributesMultibinding(AppScope::class)
class AppMigration05 @Inject constructor(
private val sessionStore: SessionStore,
private val baseDirectory: File,
) : AppMigration {
override val order: Int = 5
override suspend fun migrate() {
val allSessions = sessionStore.getAllSessions()
for (session in allSessions) {
if (session.sessionPath.isEmpty()) {
val sessionPath = File(baseDirectory, session.userId.replace(':', '_')).absolutePath
sessionStore.updateData(session.copy(sessionPath = sessionPath))
}
}
}
}

View file

@ -68,8 +68,12 @@ class PollRepository @Inject constructor(
suspend fun deletePoll( suspend fun deletePoll(
pollStartId: EventId, pollStartId: EventId,
): Result<Unit> = ): Result<Boolean> =
room.redactEvent( timelineProvider
eventId = pollStartId, .getActiveTimeline()
) .redactEvent(
eventId = pollStartId,
transactionId = null,
reason = null,
)
} }

View file

@ -29,6 +29,7 @@ import io.element.android.features.poll.impl.aPollTimelineItems
import io.element.android.features.poll.impl.anOngoingPollContent import io.element.android.features.poll.impl.anOngoingPollContent
import io.element.android.features.poll.impl.data.PollRepository import io.element.android.features.poll.impl.data.PollRepository
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.poll.PollKind import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
@ -39,6 +40,7 @@ import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.test.timeline.LiveTimelineProvider import io.element.android.libraries.matrix.test.timeline.LiveTimelineProvider
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.assert import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
@ -455,47 +457,53 @@ import org.junit.Test
@Test @Test
fun `delete confirms`() = runTest { fun `delete confirms`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(pollEventId)) val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(pollEventId))
val redactEventLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String? -> Result.success(true) }
timeline.redactEventLambda = redactEventLambda
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
awaitDefaultItem() awaitDefaultItem()
awaitPollLoaded().eventSink(CreatePollEvents.Delete(confirmed = false)) awaitPollLoaded().eventSink(CreatePollEvents.Delete(confirmed = false))
awaitDeleteConfirmation() awaitDeleteConfirmation()
assertThat(fakeMatrixRoom.redactEventEventIdParam).isNull() assert(redactEventLambda).isNeverCalled()
} }
} }
@Test @Test
fun `delete can be cancelled`() = runTest { fun `delete can be cancelled`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(pollEventId)) val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(pollEventId))
val redactEventLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String? -> Result.success(true) }
timeline.redactEventLambda = redactEventLambda
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
awaitDefaultItem() awaitDefaultItem()
awaitPollLoaded().eventSink(CreatePollEvents.Delete(confirmed = false)) awaitPollLoaded().eventSink(CreatePollEvents.Delete(confirmed = false))
assertThat(fakeMatrixRoom.redactEventEventIdParam).isNull()
awaitDeleteConfirmation().eventSink(CreatePollEvents.HideConfirmation) awaitDeleteConfirmation().eventSink(CreatePollEvents.HideConfirmation)
awaitPollLoaded().apply { awaitPollLoaded().apply {
assertThat(showDeleteConfirmation).isFalse() assertThat(showDeleteConfirmation).isFalse()
} }
assertThat(fakeMatrixRoom.redactEventEventIdParam).isNull() assert(redactEventLambda).isNeverCalled()
} }
} }
@Test @Test
fun `delete can be confirmed`() = runTest { fun `delete can be confirmed`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(pollEventId)) val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(pollEventId))
val redactEventLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String? -> Result.success(true) }
timeline.redactEventLambda = redactEventLambda
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
awaitDefaultItem() awaitDefaultItem()
awaitPollLoaded().eventSink(CreatePollEvents.Delete(confirmed = false)) awaitPollLoaded().eventSink(CreatePollEvents.Delete(confirmed = false))
assertThat(fakeMatrixRoom.redactEventEventIdParam).isNull()
awaitDeleteConfirmation().eventSink(CreatePollEvents.Delete(confirmed = true)) awaitDeleteConfirmation().eventSink(CreatePollEvents.Delete(confirmed = true))
awaitPollLoaded().apply { awaitPollLoaded().apply {
assertThat(showDeleteConfirmation).isFalse() assertThat(showDeleteConfirmation).isFalse()
} }
assertThat(fakeMatrixRoom.redactEventEventIdParam).isEqualTo(pollEventId) assert(redactEventLambda)
.isCalledOnce()
.with(value(pollEventId), value(null), any())
} }
} }

View file

@ -287,7 +287,8 @@ class DefaultBugReporterTest {
oidcData = null, oidcData = null,
refreshToken = null, refreshToken = null,
slidingSyncProxy = null, slidingSyncProxy = null,
passphrase = null passphrase = null,
sessionPath = "session",
) )
@Test @Test
fun `test sendBugReport error`() = runTest { fun `test sendBugReport error`() = runTest {

View file

@ -51,5 +51,6 @@ fun aSessionData(
isTokenValid = isTokenValid, isTokenValid = isTokenValid,
loginType = LoginType.UNKNOWN, loginType = LoginType.UNKNOWN,
passphrase = null, passphrase = null,
sessionPath = "/a/path/to/a/session",
) )
} }

View file

@ -162,7 +162,7 @@ jsoup = "org.jsoup:jsoup:1.17.2"
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" } appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = "app.cash.molecule:molecule-runtime:2.0.0" molecule-runtime = "app.cash.molecule:molecule-runtime:2.0.0"
timber = "com.jakewharton.timber:timber:5.0.1" timber = "com.jakewharton.timber:timber:5.0.1"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.24" matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.25"
matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" } matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" }
matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" } matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" }
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }

View file

@ -105,4 +105,20 @@ interface MatrixClient : Closeable {
suspend fun getRecentlyVisitedRooms(): Result<List<RoomId>> suspend fun getRecentlyVisitedRooms(): Result<List<RoomId>>
suspend fun resolveRoomAlias(roomAlias: RoomAlias): Result<ResolvedRoomAlias> suspend fun resolveRoomAlias(roomAlias: RoomAlias): Result<ResolvedRoomAlias>
suspend fun getRoomPreviewFromRoomId(roomId: RoomId, serverNames: List<String>): Result<RoomPreview> suspend fun getRoomPreviewFromRoomId(roomId: RoomId, serverNames: List<String>): Result<RoomPreview>
/**
* Enables or disables the sending queue, according to the given parameter.
*
* The sending queue automatically disables itself whenever sending an
* event with it failed (e.g. sending an event via the Timeline),
* so it's required to manually re-enable it as soon as
* connectivity is back on the device.
*/
suspend fun setAllSendQueuesEnabled(enabled: Boolean)
/**
* Returns a flow of room IDs that have send queue being disabled.
* This flow will emit a new value whenever the send queue is disabled for a room.
*/
fun sendQueueDisabledFlow(): Flow<RoomId>
} }

View file

@ -130,8 +130,6 @@ interface MatrixRoom : Closeable {
suspend fun sendMessage(body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit> suspend fun sendMessage(body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit>
suspend fun redactEvent(eventId: EventId, reason: String? = null): Result<Unit>
suspend fun sendImage( suspend fun sendImage(
file: File, file: File,
thumbnailFile: File?, thumbnailFile: File?,
@ -160,7 +158,7 @@ interface MatrixRoom : Closeable {
suspend fun retrySendMessage(transactionId: TransactionId): Result<Unit> suspend fun retrySendMessage(transactionId: TransactionId): Result<Unit>
suspend fun cancelSend(transactionId: TransactionId): Result<Unit> suspend fun cancelSend(transactionId: TransactionId): Result<Boolean>
suspend fun leave(): Result<Unit> suspend fun leave(): Result<Unit>
@ -337,5 +335,7 @@ interface MatrixRoom : Closeable {
*/ */
suspend fun sendCallNotificationIfNeeded(): Result<Unit> suspend fun sendCallNotificationIfNeeded(): Result<Unit>
suspend fun setSendQueueEnabled(enabled: Boolean)
override fun close() = destroy() override fun close() = destroy()
} }

View file

@ -83,6 +83,8 @@ interface Timeline : AutoCloseable {
progressCallback: ProgressCallback? progressCallback: ProgressCallback?
): Result<MediaUploadHandler> ): Result<MediaUploadHandler>
suspend fun redactEvent(eventId: EventId?, transactionId: TransactionId?, reason: String?): Result<Boolean>
suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler>
suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler>
@ -91,9 +93,7 @@ interface Timeline : AutoCloseable {
suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit> suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit>
suspend fun retrySendMessage(transactionId: TransactionId): Result<Unit> suspend fun cancelSend(transactionId: TransactionId): Result<Boolean>
suspend fun cancelSend(transactionId: TransactionId): Result<Unit>
/** /**
* Share a location message in the room. * Share a location message in the room.

View file

@ -22,7 +22,6 @@ import io.element.android.libraries.matrix.api.core.EventId
@Immutable @Immutable
sealed interface LocalEventSendState { sealed interface LocalEventSendState {
data object NotSentYet : LocalEventSendState data object NotSentYet : LocalEventSendState
data object Canceled : LocalEventSendState
data class SendingFailed( data class SendingFailed(
val error: String val error: String

View file

@ -63,7 +63,7 @@ import io.element.android.libraries.matrix.impl.roomlist.RustRoomListService
import io.element.android.libraries.matrix.impl.sync.RustSyncService import io.element.android.libraries.matrix.impl.sync.RustSyncService
import io.element.android.libraries.matrix.impl.usersearch.UserProfileMapper import io.element.android.libraries.matrix.impl.usersearch.UserProfileMapper
import io.element.android.libraries.matrix.impl.usersearch.UserSearchResultMapper import io.element.android.libraries.matrix.impl.usersearch.UserSearchResultMapper
import io.element.android.libraries.matrix.impl.util.SessionDirectoryNameProvider import io.element.android.libraries.matrix.impl.util.SessionDirectoryProvider
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import io.element.android.libraries.matrix.impl.verification.RustSessionVerificationService import io.element.android.libraries.matrix.impl.verification.RustSessionVerificationService
@ -95,9 +95,11 @@ import kotlinx.coroutines.withTimeout
import org.matrix.rustcomponents.sdk.BackupState import org.matrix.rustcomponents.sdk.BackupState
import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientDelegate import org.matrix.rustcomponents.sdk.ClientDelegate
import org.matrix.rustcomponents.sdk.ClientException
import org.matrix.rustcomponents.sdk.IgnoredUsersListener import org.matrix.rustcomponents.sdk.IgnoredUsersListener
import org.matrix.rustcomponents.sdk.NotificationProcessSetup import org.matrix.rustcomponents.sdk.NotificationProcessSetup
import org.matrix.rustcomponents.sdk.PowerLevels import org.matrix.rustcomponents.sdk.PowerLevels
import org.matrix.rustcomponents.sdk.SendQueueRoomErrorListener
import org.matrix.rustcomponents.sdk.TaskHandle import org.matrix.rustcomponents.sdk.TaskHandle
import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.use
import timber.log.Timber import timber.log.Timber
@ -151,7 +153,7 @@ class RustMatrixClient(
sessionDispatcher = sessionDispatcher, sessionDispatcher = sessionDispatcher,
) )
private val sessionDirectoryNameProvider = SessionDirectoryNameProvider() private val sessionDirectoryProvider = SessionDirectoryProvider(sessionStore)
private val isLoggingOut = AtomicBoolean(false) private val isLoggingOut = AtomicBoolean(false)
@ -171,6 +173,7 @@ class RustMatrixClient(
isTokenValid = false, isTokenValid = false,
loginType = existingData.loginType, loginType = existingData.loginType,
passphrase = existingData.passphrase, passphrase = existingData.passphrase,
sessionPath = existingData.sessionPath,
) )
sessionStore.updateData(newData) sessionStore.updateData(newData)
Timber.d("Removed session data with token: '...$anonymizedToken'.") Timber.d("Removed session data with token: '...$anonymizedToken'.")
@ -198,6 +201,7 @@ class RustMatrixClient(
isTokenValid = true, isTokenValid = true,
loginType = existingData.loginType, loginType = existingData.loginType,
passphrase = existingData.passphrase, passphrase = existingData.passphrase,
sessionPath = existingData.sessionPath,
) )
sessionStore.updateData(newData) sessionStore.updateData(newData)
Timber.d("Saved new session data with token: '...$anonymizedToken'.") Timber.d("Saved new session data with token: '...$anonymizedToken'.")
@ -482,7 +486,7 @@ class RustMatrixClient(
override suspend fun clearCache() { override suspend fun clearCache() {
close() close()
baseDirectory.deleteSessionDirectory(deleteCryptoDb = false) deleteSessionDirectory(deleteCryptoDb = false)
} }
override suspend fun logout(ignoreSdkError: Boolean): String? = doLogout( override suspend fun logout(ignoreSdkError: Boolean): String? = doLogout(
@ -512,7 +516,7 @@ class RustMatrixClient(
} }
} }
close() close()
baseDirectory.deleteSessionDirectory(deleteCryptoDb = true) deleteSessionDirectory(deleteCryptoDb = true)
if (removeSession) { if (removeSession) {
sessionStore.removeSession(sessionId.value) sessionStore.removeSession(sessionId.value)
} }
@ -551,11 +555,23 @@ class RustMatrixClient(
}.distinctUntilChanged() }.distinctUntilChanged()
} }
override suspend fun setAllSendQueuesEnabled(enabled: Boolean) = withContext(sessionDispatcher) {
Timber.i("setAllSendQueuesEnabled($enabled)")
client.enableAllSendQueues(enabled)
}
override fun sendQueueDisabledFlow(): Flow<RoomId> = mxCallbackFlow {
client.subscribeToSendQueueStatus(object : SendQueueRoomErrorListener {
override fun onError(roomId: String, error: ClientException) {
trySend(RoomId(roomId))
}
})
}.buffer(Channel.UNLIMITED)
private suspend fun File.getCacheSize( private suspend fun File.getCacheSize(
includeCryptoDb: Boolean = false, includeCryptoDb: Boolean = false,
): Long = withContext(sessionDispatcher) { ): Long = withContext(sessionDispatcher) {
val sessionDirectoryName = sessionDirectoryNameProvider.provides(sessionId) val sessionDirectory = sessionDirectoryProvider.provides(sessionId) ?: return@withContext 0L
val sessionDirectory = File(this@getCacheSize, sessionDirectoryName)
if (includeCryptoDb) { if (includeCryptoDb) {
sessionDirectory.getSizeOfFiles() sessionDirectory.getSizeOfFiles()
} else { } else {
@ -571,11 +587,10 @@ class RustMatrixClient(
} }
} }
private suspend fun File.deleteSessionDirectory( private suspend fun deleteSessionDirectory(
deleteCryptoDb: Boolean = false, deleteCryptoDb: Boolean = false,
): Boolean = withContext(sessionDispatcher) { ): Boolean = withContext(sessionDispatcher) {
val sessionDirectoryName = sessionDirectoryNameProvider.provides(sessionId) val sessionDirectory = sessionDirectoryProvider.provides(sessionId) ?: return@withContext false
val sessionDirectory = File(this@deleteSessionDirectory, sessionDirectoryName)
if (deleteCryptoDb) { if (deleteCryptoDb) {
// Delete the folder and all its content // Delete the folder and all its content
sessionDirectory.deleteRecursively() sessionDirectory.deleteRecursively()

View file

@ -46,7 +46,7 @@ class RustMatrixClientFactory @Inject constructor(
private val utdTracker: UtdTracker, private val utdTracker: UtdTracker,
) { ) {
suspend fun create(sessionData: SessionData): RustMatrixClient = withContext(coroutineDispatchers.io) { suspend fun create(sessionData: SessionData): RustMatrixClient = withContext(coroutineDispatchers.io) {
val client = getBaseClientBuilder() val client = getBaseClientBuilder(sessionData.sessionPath)
.homeserverUrl(sessionData.homeserverUrl) .homeserverUrl(sessionData.homeserverUrl)
.username(sessionData.userId) .username(sessionData.userId)
.passphrase(sessionData.passphrase) .passphrase(sessionData.passphrase)
@ -71,9 +71,9 @@ class RustMatrixClientFactory @Inject constructor(
) )
} }
internal fun getBaseClientBuilder(): ClientBuilder { internal fun getBaseClientBuilder(sessionPath: String): ClientBuilder {
return ClientBuilder() return ClientBuilder()
.basePath(baseDirectory.absolutePath) .sessionPath(sessionPath)
.userAgent(userAgentProvider.provide()) .userAgent(userAgentProvider.provide())
.addRootCertificates(userCertificatesProvider.provides()) .addRootCertificates(userCertificatesProvider.provides())
.serverVersions(listOf("v1.0", "v1.1", "v1.2", "v1.3", "v1.4", "v1.5")) .serverVersions(listOf("v1.0", "v1.1", "v1.2", "v1.3", "v1.4", "v1.5"))

View file

@ -1,36 +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.libraries.matrix.impl.auth
import io.element.android.libraries.matrix.api.auth.OidcConfig
import org.matrix.rustcomponents.sdk.OidcConfiguration
val oidcConfiguration: OidcConfiguration = OidcConfiguration(
clientName = "Element",
redirectUri = OidcConfig.REDIRECT_URI,
clientUri = "https://element.io",
logoUri = "https://element.io/mobile-icon.png",
tosUri = "https://element.io/acceptable-use-policy-terms",
policyUri = "https://element.io/privacy",
contacts = listOf(
"support@element.io",
),
// Some homeservers/auth issuers don't support dynamic client registration, and have to be registered manually
staticRegistrations = mapOf(
"https://id.thirdroom.io/realms/thirdroom" to "elementx",
),
)

View file

@ -0,0 +1,43 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.impl.auth
import io.element.android.libraries.matrix.api.auth.OidcConfig
import org.matrix.rustcomponents.sdk.OidcConfiguration
import java.io.File
import javax.inject.Inject
class OidcConfigurationProvider @Inject constructor(
private val baseDirectory: File,
) {
fun get(): OidcConfiguration = OidcConfiguration(
clientName = "Element",
redirectUri = OidcConfig.REDIRECT_URI,
clientUri = "https://element.io",
logoUri = "https://element.io/mobile-icon.png",
tosUri = "https://element.io/acceptable-use-policy-terms",
policyUri = "https://element.io/privacy",
contacts = listOf(
"support@element.io",
),
// Some homeservers/auth issuers don't support dynamic client registration, and have to be registered manually
staticRegistrations = mapOf(
"https://id.thirdroom.io/realms/thirdroom" to "elementx",
),
dynamicRegistrationsFile = File(baseDirectory, "oidc/registrations.json").absolutePath,
)
}

View file

@ -54,6 +54,7 @@ import org.matrix.rustcomponents.sdk.QrLoginProgressListener
import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.use
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import org.matrix.rustcomponents.sdk.AuthenticationService as RustAuthenticationService import org.matrix.rustcomponents.sdk.AuthenticationService as RustAuthenticationService
@ -68,17 +69,19 @@ class RustMatrixAuthenticationService @Inject constructor(
private val passphraseGenerator: PassphraseGenerator, private val passphraseGenerator: PassphraseGenerator,
userCertificatesProvider: UserCertificatesProvider, userCertificatesProvider: UserCertificatesProvider,
proxyProvider: ProxyProvider, proxyProvider: ProxyProvider,
private val oidcConfigurationProvider: OidcConfigurationProvider,
) : MatrixAuthenticationService { ) : MatrixAuthenticationService {
// Passphrase which will be used for new sessions. Existing sessions will use the passphrase // Passphrase which will be used for new sessions. Existing sessions will use the passphrase
// stored in the SessionData. // stored in the SessionData.
private val pendingPassphrase = getDatabasePassphrase() private val pendingPassphrase = getDatabasePassphrase()
private val sessionPath = File(baseDirectory, UUID.randomUUID().toString()).absolutePath
private val authService: RustAuthenticationService = RustAuthenticationService( private val authService: RustAuthenticationService = RustAuthenticationService(
basePath = baseDirectory.absolutePath, sessionPath = sessionPath,
passphrase = pendingPassphrase, passphrase = pendingPassphrase,
proxy = proxyProvider.provides(), proxy = proxyProvider.provides(),
userAgent = userAgentProvider.provide(), userAgent = userAgentProvider.provide(),
additionalRootCertificates = userCertificatesProvider.provides(), additionalRootCertificates = userCertificatesProvider.provides(),
oidcConfiguration = oidcConfiguration, oidcConfiguration = oidcConfigurationProvider.get(),
customSlidingSyncProxy = null, customSlidingSyncProxy = null,
sessionDelegate = null, sessionDelegate = null,
crossProcessRefreshLockId = null, crossProcessRefreshLockId = null,
@ -148,6 +151,7 @@ class RustMatrixAuthenticationService @Inject constructor(
isTokenValid = true, isTokenValid = true,
loginType = LoginType.PASSWORD, loginType = LoginType.PASSWORD,
passphrase = pendingPassphrase, passphrase = pendingPassphrase,
sessionPath = sessionPath,
) )
} }
sessionStore.storeData(sessionData) sessionStore.storeData(sessionData)
@ -196,6 +200,7 @@ class RustMatrixAuthenticationService @Inject constructor(
isTokenValid = true, isTokenValid = true,
loginType = LoginType.OIDC, loginType = LoginType.OIDC,
passphrase = pendingPassphrase, passphrase = pendingPassphrase,
sessionPath = sessionPath,
) )
} }
pendingOidcAuthenticationData?.close() pendingOidcAuthenticationData?.close()
@ -211,11 +216,11 @@ class RustMatrixAuthenticationService @Inject constructor(
override suspend fun loginWithQrCode(qrCodeData: MatrixQrCodeLoginData, progress: (QrCodeLoginStep) -> Unit) = override suspend fun loginWithQrCode(qrCodeData: MatrixQrCodeLoginData, progress: (QrCodeLoginStep) -> Unit) =
withContext(coroutineDispatchers.io) { withContext(coroutineDispatchers.io) {
runCatching { runCatching {
val client = rustMatrixClientFactory.getBaseClientBuilder() val client = rustMatrixClientFactory.getBaseClientBuilder(sessionPath)
.passphrase(pendingPassphrase) .passphrase(pendingPassphrase)
.buildWithQrCode( .buildWithQrCode(
qrCodeData = (qrCodeData as SdkQrCodeLoginData).rustQrCodeData, qrCodeData = (qrCodeData as SdkQrCodeLoginData).rustQrCodeData,
oidcConfiguration = oidcConfiguration, oidcConfiguration = oidcConfigurationProvider.get(),
progressListener = object : QrLoginProgressListener { progressListener = object : QrLoginProgressListener {
override fun onUpdate(state: QrLoginProgress) { override fun onUpdate(state: QrLoginProgress) {
Timber.d("QR Code login progress: $state") Timber.d("QR Code login progress: $state")
@ -229,6 +234,7 @@ class RustMatrixAuthenticationService @Inject constructor(
isTokenValid = true, isTokenValid = true,
loginType = LoginType.QR, loginType = LoginType.QR,
passphrase = pendingPassphrase, passphrase = pendingPassphrase,
sessionPath = sessionPath,
) )
sessionStore.storeData(sessionData) sessionStore.storeData(sessionData)
SessionId(sessionData.userId) SessionId(sessionData.userId)

View file

@ -25,6 +25,7 @@ internal fun Session.toSessionData(
isTokenValid: Boolean, isTokenValid: Boolean,
loginType: LoginType, loginType: LoginType,
passphrase: String?, passphrase: String?,
sessionPath: String,
) = SessionData( ) = SessionData(
userId = userId, userId = userId,
deviceId = deviceId, deviceId = deviceId,
@ -37,4 +38,5 @@ internal fun Session.toSessionData(
isTokenValid = isTokenValid, isTokenValid = isTokenValid,
loginType = loginType, loginType = loginType,
passphrase = passphrase, passphrase = passphrase,
sessionPath = sessionPath,
) )

View file

@ -84,6 +84,7 @@ import org.matrix.rustcomponents.sdk.WidgetCapabilities
import org.matrix.rustcomponents.sdk.WidgetCapabilitiesProvider import org.matrix.rustcomponents.sdk.WidgetCapabilitiesProvider
import org.matrix.rustcomponents.sdk.getElementCallRequiredPermissions import org.matrix.rustcomponents.sdk.getElementCallRequiredPermissions
import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
import uniffi.matrix_sdk.RoomPowerLevelChanges import uniffi.matrix_sdk.RoomPowerLevelChanges
import java.io.File import java.io.File
import org.matrix.rustcomponents.sdk.Room as InnerRoom import org.matrix.rustcomponents.sdk.Room as InnerRoom
@ -324,12 +325,6 @@ class RustMatrixRoom(
return liveTimeline.sendMessage(body, htmlBody, mentions) return liveTimeline.sendMessage(body, htmlBody, mentions)
} }
override suspend fun redactEvent(eventId: EventId, reason: String?) = withContext(roomDispatcher) {
runCatching {
innerRoom.redact(eventId.value, reason)
}
}
override suspend fun leave(): Result<Unit> = withContext(roomDispatcher) { override suspend fun leave(): Result<Unit> = withContext(roomDispatcher) {
runCatching { runCatching {
innerRoom.leave() innerRoom.leave()
@ -435,10 +430,10 @@ class RustMatrixRoom(
} }
override suspend fun retrySendMessage(transactionId: TransactionId): Result<Unit> { override suspend fun retrySendMessage(transactionId: TransactionId): Result<Unit> {
return liveTimeline.retrySendMessage(transactionId) return Result.failure(UnsupportedOperationException("Not supported"))
} }
override suspend fun cancelSend(transactionId: TransactionId): Result<Unit> { override suspend fun cancelSend(transactionId: TransactionId): Result<Boolean> {
return liveTimeline.cancelSend(transactionId) return liveTimeline.cancelSend(transactionId)
} }
@ -600,6 +595,11 @@ class RustMatrixRoom(
innerRoom.sendCallNotificationIfNeeded() innerRoom.sendCallNotificationIfNeeded()
} }
override suspend fun setSendQueueEnabled(enabled: Boolean) = withContext(roomDispatcher) {
Timber.d("setSendQueuesEnabled: $enabled")
innerRoom.enableSendQueue(enabled)
}
private fun createTimeline( private fun createTimeline(
timeline: InnerTimeline, timeline: InnerTimeline,
isLive: Boolean, isLive: Boolean,

View file

@ -279,12 +279,32 @@ class RustTimeline(
override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit> = withContext(dispatcher) { override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit> = withContext(dispatcher) {
messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()).use { content -> messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()).use { content ->
runCatching { runCatching<Unit> {
inner.send(content) inner.send(content)
} }
} }
} }
override suspend fun redactEvent(eventId: EventId?, transactionId: TransactionId?, reason: String?): Result<Boolean> = withContext(dispatcher) {
runCatching {
when {
eventId != null -> {
inner.getEventTimelineItemByEventId(eventId.value).use {
inner.redactEvent(item = it, reason = reason)
}
}
transactionId != null -> {
inner.getEventTimelineItemByTransactionId(transactionId.value).use {
inner.redactEvent(item = it, reason = reason)
}
}
else -> {
error("Either eventId or transactionId must be non-null")
}
}
}
}
override suspend fun editMessage( override suspend fun editMessage(
originalEventId: EventId?, originalEventId: EventId?,
transactionId: TransactionId?, transactionId: TransactionId?,
@ -293,21 +313,24 @@ class RustTimeline(
mentions: List<Mention>, mentions: List<Mention>,
): Result<Unit> = ): Result<Unit> =
withContext(dispatcher) { withContext(dispatcher) {
if (originalEventId != null) { runCatching<Unit> {
runCatching { when {
val editedEvent = specialModeEventTimelineItem ?: inner.getEventTimelineItemByEventId(originalEventId.value) originalEventId != null -> {
editedEvent.use { val editedEvent = specialModeEventTimelineItem ?: inner.getEventTimelineItemByEventId(originalEventId.value)
inner.edit( editedEvent.use {
newContent = messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()), inner.edit(
editItem = it, newContent = messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()),
) editItem = it,
)
}
specialModeEventTimelineItem = null
}
transactionId != null -> {
error("Editing local echo is not supported yet.")
}
else -> {
error("Either originalEventId or transactionId must be non null")
} }
specialModeEventTimelineItem = null
}
} else {
runCatching {
transactionId?.let { cancelSend(it) }
inner.send(messageEventContentFromParts(body, htmlBody))
} }
} }
} }
@ -426,17 +449,7 @@ class RustTimeline(
} }
} }
override suspend fun retrySendMessage(transactionId: TransactionId): Result<Unit> = withContext(dispatcher) { override suspend fun cancelSend(transactionId: TransactionId): Result<Boolean> = redactEvent(eventId = null, transactionId = transactionId, reason = null)
runCatching {
inner.retrySend(transactionId.value)
}
}
override suspend fun cancelSend(transactionId: TransactionId): Result<Unit> = withContext(dispatcher) {
runCatching {
inner.cancelSend(transactionId.value)
}
}
override suspend fun sendLocation( override suspend fun sendLocation(
body: String, body: String,

View file

@ -79,7 +79,6 @@ fun RustEventSendState?.map(): LocalEventSendState? {
RustEventSendState.NotSentYet -> LocalEventSendState.NotSentYet RustEventSendState.NotSentYet -> LocalEventSendState.NotSentYet
is RustEventSendState.SendingFailed -> LocalEventSendState.SendingFailed(error) is RustEventSendState.SendingFailed -> LocalEventSendState.SendingFailed(error)
is RustEventSendState.Sent -> LocalEventSendState.Sent(EventId(eventId)) is RustEventSendState.Sent -> LocalEventSendState.Sent(EventId(eventId))
RustEventSendState.Cancelled -> LocalEventSendState.Canceled
} }
} }

View file

@ -17,10 +17,15 @@
package io.element.android.libraries.matrix.impl.util package io.element.android.libraries.matrix.impl.util
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.sessionstorage.api.SessionStore
import java.io.File
import javax.inject.Inject
class SessionDirectoryNameProvider { class SessionDirectoryProvider @Inject constructor(
// Rust sanitises the user ID replacing invalid characters with an _ private val sessionStore: SessionStore,
fun provides(sessionId: SessionId): String { ) {
return sessionId.value.replace(":", "_") suspend fun provides(sessionId: SessionId): File? {
val path = sessionStore.getSession(sessionId.value)?.sessionPath ?: return null
return File(path)
} }
} }

View file

@ -48,13 +48,16 @@ import io.element.android.libraries.matrix.test.roomdirectory.FakeRoomDirectoryS
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.sync.FakeSyncService import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.simulateLongTask import io.element.android.tests.testutils.simulateLongTask
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import java.util.Optional import java.util.Optional
@ -298,4 +301,13 @@ class FakeMatrixClient(
} }
override fun getRoomInfoFlow(roomId: RoomId) = getRoomInfoFlowLambda(roomId) override fun getRoomInfoFlow(roomId: RoomId) = getRoomInfoFlowLambda(roomId)
var setAllSendQueuesEnabledLambda = lambdaRecorder(ensureNeverCalled = true) { _: Boolean ->
// no-op
}
override suspend fun setAllSendQueuesEnabled(enabled: Boolean) = setAllSendQueuesEnabledLambda(enabled)
var sendQueueDisabledFlow = emptyFlow<RoomId>()
override fun sendQueueDisabledFlow(): Flow<RoomId> = sendQueueDisabledFlow
} }

View file

@ -113,7 +113,7 @@ class FakeMatrixRoom(
private var updateUserRoleResult = Result.success(Unit) private var updateUserRoleResult = Result.success(Unit)
private var toggleReactionResult = Result.success(Unit) private var toggleReactionResult = Result.success(Unit)
private var retrySendMessageResult = Result.success(Unit) private var retrySendMessageResult = Result.success(Unit)
private var cancelSendResult = Result.success(Unit) private var cancelSendResult = Result.success(true)
private var forwardEventResult = Result.success(Unit) private var forwardEventResult = Result.success(Unit)
private var reportContentResult = Result.success(Unit) private var reportContentResult = Result.success(Unit)
private var kickUserResult = Result.success(Unit) private var kickUserResult = Result.success(Unit)
@ -282,7 +282,7 @@ class FakeMatrixRoom(
return retrySendMessageResult return retrySendMessageResult
} }
override suspend fun cancelSend(transactionId: TransactionId): Result<Unit> { override suspend fun cancelSend(transactionId: TransactionId): Result<Boolean> {
cancelSendCount++ cancelSendCount++
return cancelSendResult return cancelSendResult
} }
@ -295,14 +295,6 @@ class FakeMatrixRoom(
return eventPermalinkResult(eventId) return eventPermalinkResult(eventId)
} }
var redactEventEventIdParam: EventId? = null
private set
override suspend fun redactEvent(eventId: EventId, reason: String?): Result<Unit> {
redactEventEventIdParam = eventId
return Result.success(Unit)
}
override suspend fun leave(): Result<Unit> { override suspend fun leave(): Result<Unit> {
return leaveRoomLambda() return leaveRoomLambda()
} }
@ -533,6 +525,9 @@ class FakeMatrixRoom(
return sendCallNotificationIfNeededResult() return sendCallNotificationIfNeededResult()
} }
var setSendQueueEnabledLambda = { _: Boolean -> }
override suspend fun setSendQueueEnabled(enabled: Boolean) = setSendQueueEnabledLambda(enabled)
override fun getWidgetDriver(widgetSettings: MatrixWidgetSettings): Result<MatrixWidgetDriver> = getWidgetDriverResult override fun getWidgetDriver(widgetSettings: MatrixWidgetSettings): Result<MatrixWidgetDriver> = getWidgetDriverResult
fun givenRoomMembersState(state: MatrixRoomMembersState) { fun givenRoomMembersState(state: MatrixRoomMembersState) {
@ -631,7 +626,7 @@ class FakeMatrixRoom(
retrySendMessageResult = result retrySendMessageResult = result
} }
fun givenCancelSendResult(result: Result<Unit>) { fun givenCancelSendResult(result: Result<Boolean>) {
cancelSendResult = result cancelSendResult = result
} }

View file

@ -69,6 +69,16 @@ class FakeTimeline(
mentions: List<Mention>, mentions: List<Mention>,
): Result<Unit> = sendMessageLambda(body, htmlBody, mentions) ): Result<Unit> = sendMessageLambda(body, htmlBody, mentions)
var redactEventLambda: (eventId: EventId?, transactionId: TransactionId?, reason: String?) -> Result<Boolean> = { _, _, _ ->
Result.success(true)
}
override suspend fun redactEvent(
eventId: EventId?,
transactionId: TransactionId?,
reason: String?
): Result<Boolean> = redactEventLambda(eventId, transactionId, reason)
var editMessageLambda: ( var editMessageLambda: (
originalEventId: EventId?, originalEventId: EventId?,
transactionId: TransactionId?, transactionId: TransactionId?,
@ -219,11 +229,7 @@ class FakeTimeline(
var forwardEventLambda: (eventId: EventId, roomIds: List<RoomId>) -> Result<Unit> = { _, _ -> Result.success(Unit) } var forwardEventLambda: (eventId: EventId, roomIds: List<RoomId>) -> Result<Unit> = { _, _ -> Result.success(Unit) }
override suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit> = forwardEventLambda(eventId, roomIds) override suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit> = forwardEventLambda(eventId, roomIds)
var retrySendMessageLambda: (transactionId: TransactionId) -> Result<Unit> = { Result.success(Unit) } override suspend fun cancelSend(transactionId: TransactionId): Result<Boolean> = redactEvent(null, transactionId, null)
override suspend fun retrySendMessage(transactionId: TransactionId): Result<Unit> = retrySendMessageLambda(transactionId)
var cancelSendLambda: (transactionId: TransactionId) -> Result<Unit> = { Result.success(Unit) }
override suspend fun cancelSend(transactionId: TransactionId): Result<Unit> = cancelSendLambda(transactionId)
var sendLocationLambda: ( var sendLocationLambda: (
body: String, body: String,

View file

@ -63,6 +63,7 @@ dependencies {
testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.pushstore.test) testImplementation(projects.libraries.pushstore.test)
testImplementation(projects.libraries.sessionStorage.implMemory) testImplementation(projects.libraries.sessionStorage.implMemory)
testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
testImplementation(projects.services.toolbox.test) testImplementation(projects.services.toolbox.test)
} }

View file

@ -19,7 +19,6 @@ package io.element.android.libraries.pushproviders.firebase
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.A_USER_ID_2
@ -31,11 +30,10 @@ import io.element.android.libraries.pushproviders.api.PusherSubscriber
import io.element.android.libraries.pushstore.api.UserPushStoreFactory import io.element.android.libraries.pushstore.api.UserPushStoreFactory
import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStore import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStore
import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory
import io.element.android.libraries.sessionstorage.api.LoginType
import io.element.android.libraries.sessionstorage.api.SessionData
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.impl.memory.InMemoryMultiSessionsStore import io.element.android.libraries.sessionstorage.impl.memory.InMemoryMultiSessionsStore
import io.element.android.libraries.sessionstorage.impl.memory.InMemorySessionStore import io.element.android.libraries.sessionstorage.impl.memory.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -165,22 +163,4 @@ class DefaultFirebaseNewTokenHandlerTest {
firebaseStore = firebaseStore firebaseStore = firebaseStore
) )
} }
private fun aSessionData(
sessionId: SessionId,
): SessionData {
return SessionData(
userId = sessionId.value,
deviceId = "aDeviceId",
accessToken = "anAccessToken",
refreshToken = "aRefreshToken",
homeserverUrl = "aHomeserverUrl",
oidcData = null,
slidingSyncProxy = null,
loginTimestamp = null,
isTokenValid = true,
loginType = LoginType.UNKNOWN,
passphrase = null,
)
}
} }

View file

@ -44,4 +44,6 @@ data class SessionData(
val loginType: LoginType, val loginType: LoginType,
/** The optional passphrase used to encrypt data in the SDK local store. */ /** The optional passphrase used to encrypt data in the SDK local store. */
val passphrase: String?, val passphrase: String?,
/** The path to the session data stored in the filesystem. */
val sessionPath: String,
) )

View file

@ -34,6 +34,7 @@ internal fun SessionData.toDbModel(): DbSessionData {
isTokenValid = if (isTokenValid) 1L else 0L, isTokenValid = if (isTokenValid) 1L else 0L,
loginType = loginType.name, loginType = loginType.name,
passphrase = passphrase, passphrase = passphrase,
sessionPath = sessionPath,
) )
} }
@ -50,5 +51,6 @@ internal fun DbSessionData.toApiModel(): SessionData {
isTokenValid = isTokenValid == 1L, isTokenValid = isTokenValid == 1L,
loginType = LoginType.fromName(loginType ?: LoginType.UNKNOWN.name), loginType = LoginType.fromName(loginType ?: LoginType.UNKNOWN.name),
passphrase = passphrase, passphrase = passphrase,
sessionPath = sessionPath,
) )
} }

View file

@ -32,7 +32,9 @@ import io.element.encrypteddb.passphrase.RandomSecretPassphraseProvider
object SessionStorageModule { object SessionStorageModule {
@Provides @Provides
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
fun provideMatrixDatabase(@ApplicationContext context: Context): SessionDatabase { fun provideMatrixDatabase(
@ApplicationContext context: Context,
): SessionDatabase {
val name = "session_database" val name = "session_database"
val secretFile = context.getDatabasePath("$name.key") val secretFile = context.getDatabasePath("$name.key")

View file

@ -23,7 +23,9 @@ CREATE TABLE SessionData (
isTokenValid INTEGER NOT NULL DEFAULT 1, isTokenValid INTEGER NOT NULL DEFAULT 1,
loginType TEXT, loginType TEXT,
-- added in version 5 -- added in version 5
passphrase TEXT passphrase TEXT,
-- added in version 6
sessionPath TEXT NOT NULL DEFAULT ""
); );

View file

@ -0,0 +1,4 @@
-- Migrate DB from version 7
-- Add sessionPath so we can track the anonymized path for the session files dir
ALTER TABLE SessionData ADD COLUMN sessionPath TEXT NOT NULL DEFAULT "";

View file

@ -144,6 +144,7 @@ class DatabaseSessionStoreTest {
isTokenValid = 1, isTokenValid = 1,
loginType = null, loginType = null,
passphrase = "aPassphrase", passphrase = "aPassphrase",
sessionPath = "sessionPath",
) )
val secondSessionData = SessionData( val secondSessionData = SessionData(
userId = "userId", userId = "userId",
@ -157,6 +158,7 @@ class DatabaseSessionStoreTest {
isTokenValid = 1, isTokenValid = 1,
loginType = null, loginType = null,
passphrase = "aPassphraseAltered", passphrase = "aPassphraseAltered",
sessionPath = "sessionPath",
) )
assertThat(firstSessionData.userId).isEqualTo(secondSessionData.userId) assertThat(firstSessionData.userId).isEqualTo(secondSessionData.userId)
assertThat(firstSessionData.loginTimestamp).isNotEqualTo(secondSessionData.loginTimestamp) assertThat(firstSessionData.loginTimestamp).isNotEqualTo(secondSessionData.loginTimestamp)
@ -193,6 +195,7 @@ class DatabaseSessionStoreTest {
isTokenValid = 1, isTokenValid = 1,
loginType = null, loginType = null,
passphrase = "aPassphrase", passphrase = "aPassphrase",
sessionPath = "sessionPath",
) )
val secondSessionData = SessionData( val secondSessionData = SessionData(
userId = "userIdUnknown", userId = "userIdUnknown",
@ -206,6 +209,7 @@ class DatabaseSessionStoreTest {
isTokenValid = 1, isTokenValid = 1,
loginType = null, loginType = null,
passphrase = "aPassphraseAltered", passphrase = "aPassphraseAltered",
sessionPath = "sessionPath",
) )
assertThat(firstSessionData.userId).isNotEqualTo(secondSessionData.userId) assertThat(firstSessionData.userId).isNotEqualTo(secondSessionData.userId)

View file

@ -31,4 +31,5 @@ internal fun aSessionData() = SessionData(
isTokenValid = 1, isTokenValid = 1,
loginType = LoginType.UNKNOWN.name, loginType = LoginType.UNKNOWN.name,
passphrase = null, passphrase = null,
sessionPath = "sessionPath",
) )

View file

@ -36,5 +36,6 @@ fun aSessionData(
isTokenValid = isTokenValid, isTokenValid = isTokenValid,
loginType = LoginType.UNKNOWN, loginType = LoginType.UNKNOWN,
passphrase = null, passphrase = null,
sessionPath = "/a/path/to/a/session",
) )
} }

View file

@ -30,6 +30,7 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.impl.RustMatrixClientFactory import io.element.android.libraries.matrix.impl.RustMatrixClientFactory
import io.element.android.libraries.matrix.impl.analytics.UtdTracker import io.element.android.libraries.matrix.impl.analytics.UtdTracker
import io.element.android.libraries.matrix.impl.auth.OidcConfigurationProvider
import io.element.android.libraries.matrix.impl.auth.RustMatrixAuthenticationService import io.element.android.libraries.matrix.impl.auth.RustMatrixAuthenticationService
import io.element.android.libraries.network.useragent.SimpleUserAgentProvider import io.element.android.libraries.network.useragent.SimpleUserAgentProvider
import io.element.android.libraries.sessionstorage.api.LoggedInState import io.element.android.libraries.sessionstorage.api.LoggedInState
@ -66,6 +67,7 @@ class MainActivity : ComponentActivity() {
passphraseGenerator = NullPassphraseGenerator(), passphraseGenerator = NullPassphraseGenerator(),
userCertificatesProvider = userCertificatesProvider, userCertificatesProvider = userCertificatesProvider,
proxyProvider = proxyProvider, proxyProvider = proxyProvider,
oidcConfigurationProvider = OidcConfigurationProvider(baseDirectory),
) )
} }

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:94dca1fe2f633ff25724bfc287718f506dee8a18710518fd682de67d9d3d2350
size 14742

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e2588aa42d2fd215f4019e823c8aae87b9ca44b5164b382cc6015f726945168c
size 13478

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e924c2fc2dc6b66146364d999b77874c883fae2abac9390a1a48c90f61ebf3f7 oid sha256:de9876d82b13a220cd296f6117bedf7c1a260d9560978701065ce068e22b1c8b
size 4928 size 4485

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:8a4dd7d2d6607135bb9e18f684a34df17e0ef1fd2b48fb85bbc6ba82b82e09c5 oid sha256:cce57f0b6d304c9871fdb3f8a13e1dd787493d95d5edde9631a18e45b9756acc
size 6213 size 5704

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3ee2232b7d0feb4c7310a695f3b1e58ddb466970a8cef4051e35f7d83dbb1629 oid sha256:5dc95cafaeda3d0cd45ce37408de0858543f1de07a29b18d165cf2c9a438e9c6
size 4863 size 4465

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:bfa1209e3c4c97dffe0f633643a8e4da3509d57998cb047651a7e18daeddb566 oid sha256:e7d670f258934a3b9b016464f74739a0eff902ec9a184bfb166b116e01de36da
size 6080 size 5698

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f5503888d07219df789c5394553491ad4fe142e7f806d92d2aad95d626d343bd oid sha256:29cceba22078f6b7791bd5785c7654b68114a7858d043aa5e3b71764089c8ab7
size 30501 size 31482

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:45309834e68ab0b6c6efbb5bfe26cc61f2b9fc171df8734df69dd9c9b2fd9b06 oid sha256:00e60c9996b9f76848eaa6178aa50ecc4aedd880536800bd307fb68db9c17011
size 34219 size 35177

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:724c3407a1b62b22549081165b40faaf26d151e8901209f58dc09465586e6c3c oid sha256:c3bb375b7a5e3e1566c73693cb19e9a38a33b9f40148ffd219a2e0c2037d7a97
size 30971 size 32137

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1675d944150065d33f6b375ae7bc617385099a4bcb76e55a213f322146788d0f oid sha256:57c3265ca6432efdc69ed45acdb719a10a72544c520b32008c203294bd92f18d
size 34402 size 35571

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:2a90813041638a500f77ea67b7d2bd5eda67340aa3167694dd056b3d3b335170 oid sha256:af29bab33aa3acd1cf0141d339c54764b76ef79978519ad33078dadb2bab39b5
size 49858 size 50347

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b7234d292e54eda42bce2d3ebf5077d663c18e15f5d4fd40d8fedd05696fcab7 oid sha256:d45f5492470427b578e9a89147114df46b8e97669aa821356ffcb1b13669c360
size 70828 size 71317

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:28b52a5faf15fed991aff80f2dc7f4da19d3a8eb5f274d880debfd99f502b3d5 oid sha256:90d6e29da0c6e203b5337496aef692f579f3754c3709735c98d650fe4e519b5b
size 328963 size 310747

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a8cd185f8149044a4254d418ff5ee670ad85c8d8f2ce235a15045d170c02b8f5 oid sha256:b3534ec458fa98111819b74453086b203dbd69cbc9aee70141203444f56b01f2
size 85699 size 84090

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f7aa599537bff4a9db1cf532eab36fc20c823cebb86fded64b53d9084693467b oid sha256:fa1157e50a67d9fbb25dcb2fba467a684709a329ed2dc6aca9178ccd66e654b7
size 51322 size 51816

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:566646a9a50e33618141ad6c36291b6e4b620f36d7735723adcca6adf9fbf935 oid sha256:66ef24a752440232d199c490b3d75db9f9348b5f9665e1f3a668f9496634fd34
size 63060 size 63525

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