Update Matrix Room API and allow media swipe on pinned event only.

This commit is contained in:
Benoit Marty
2025-02-17 16:45:05 +01:00
parent 37618600d7
commit c02436d3f0
30 changed files with 268 additions and 181 deletions

View File

@@ -71,6 +71,7 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.alias.matches
import io.element.android.libraries.matrix.api.room.joinedRoomMembers
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.ui.messages.LocalRoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
@@ -187,8 +188,11 @@ class MessagesFlowNode @AssistedInject constructor(
callbacks.forEach { it.onRoomDetailsClick() }
}
override fun onEventClick(event: TimelineItem.Event): Boolean {
return processEventClick(event)
override fun onEventClick(isLive: Boolean, event: TimelineItem.Event): Boolean {
return processEventClick(
timelineMode = if (isLive) Timeline.Mode.LIVE else Timeline.Mode.FOCUSED_ON_EVENT,
event = event,
)
}
override fun onPreviewAttachments(attachments: ImmutableList<Attachment>) {
@@ -316,7 +320,10 @@ class MessagesFlowNode @AssistedInject constructor(
NavTarget.PinnedMessagesList -> {
val callback = object : PinnedMessagesListNode.Callback {
override fun onEventClick(event: TimelineItem.Event) {
processEventClick(event)
processEventClick(
timelineMode = Timeline.Mode.PINNED_EVENTS,
event = event,
)
}
override fun onUserDataClick(userId: UserId) {
@@ -358,11 +365,14 @@ class MessagesFlowNode @AssistedInject constructor(
callbacks.forEach { it.onPermalinkClick(permalinkData, pushToBackstack = false) }
}
private fun processEventClick(event: TimelineItem.Event): Boolean {
private fun processEventClick(
timelineMode: Timeline.Mode,
event: TimelineItem.Event,
): Boolean {
val navTarget = when (event.content) {
is TimelineItemImageContent -> {
buildMediaViewerNavTarget(
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos,
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(timelineMode),
event = event,
content = event.content,
mediaSource = event.content.mediaSource,
@@ -374,7 +384,7 @@ class MessagesFlowNode @AssistedInject constructor(
if encrypted on certain bridges */
event.content.preferredMediaSource?.let { preferredMediaSource ->
buildMediaViewerNavTarget(
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos,
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(timelineMode),
event = event,
content = event.content,
mediaSource = preferredMediaSource,
@@ -384,7 +394,7 @@ class MessagesFlowNode @AssistedInject constructor(
}
is TimelineItemVideoContent -> {
buildMediaViewerNavTarget(
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos,
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(timelineMode),
event = event,
content = event.content,
mediaSource = event.content.mediaSource,
@@ -393,7 +403,7 @@ class MessagesFlowNode @AssistedInject constructor(
}
is TimelineItemFileContent -> {
buildMediaViewerNavTarget(
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios,
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(timelineMode),
event = event,
content = event.content,
mediaSource = event.content.mediaSource,
@@ -402,7 +412,7 @@ class MessagesFlowNode @AssistedInject constructor(
}
is TimelineItemAudioContent -> {
buildMediaViewerNavTarget(
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios,
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(timelineMode),
event = event,
content = event.content,
mediaSource = event.content.mediaSource,

View File

@@ -89,7 +89,7 @@ class MessagesNode @AssistedInject constructor(
interface Callback : Plugin {
fun onRoomDetailsClick()
fun onEventClick(event: TimelineItem.Event): Boolean
fun onEventClick(isLive: Boolean, event: TimelineItem.Event): Boolean
fun onPreviewAttachments(attachments: ImmutableList<Attachment>)
fun onUserDataClick(userId: UserId)
fun onPermalinkClick(data: PermalinkData)
@@ -120,12 +120,12 @@ class MessagesNode @AssistedInject constructor(
callbacks.forEach { it.onRoomDetailsClick() }
}
private fun onEventClick(event: TimelineItem.Event): Boolean {
private fun onEventClick(isLive: Boolean, event: TimelineItem.Event): Boolean {
// Note: cannot use `callbacks.all { it.onEventClick(event) }` because:
// - if callbacks is empty, it will return true and we want to return false.
// - if a callback returns false, the other callback will not be invoked.
return callbacks.takeIf { it.isNotEmpty() }
?.map { it.onEventClick(event) }
?.map { it.onEventClick(isLive, event) }
?.all { it }
.orFalse()
}

View File

@@ -109,7 +109,7 @@ fun MessagesView(
state: MessagesState,
onBackClick: () -> Unit,
onRoomDetailsClick: () -> Unit,
onEventContentClick: (event: TimelineItem.Event) -> Boolean,
onEventContentClick: (isLive: Boolean, event: TimelineItem.Event) -> Boolean,
onUserDataClick: (UserId) -> Unit,
onLinkClick: (String, Boolean) -> Unit,
onSendLocationClick: () -> Unit,
@@ -140,7 +140,7 @@ fun MessagesView(
fun onContentClick(event: TimelineItem.Event) {
Timber.v("onMessageClick= ${event.id}")
val hideKeyboard = onEventContentClick(event)
val hideKeyboard = onEventContentClick(state.timelineState.isLive, event)
if (hideKeyboard) {
localView.hideKeyboard()
}
@@ -535,7 +535,7 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
state = state,
onBackClick = {},
onRoomDetailsClick = {},
onEventContentClick = { false },
onEventContentClick = { _, _ -> false },
onUserDataClick = {},
onLinkClick = { _, _ -> },
onSendLocationClick = {},

View File

@@ -33,7 +33,7 @@ internal fun MessagesViewWithIdentityChangePreview(
),
onBackClick = {},
onRoomDetailsClick = {},
onEventContentClick = { false },
onEventContentClick = { _, _ -> false },
onUserDataClick = {},
onLinkClick = { _, _ -> },
onSendLocationClick = {},

View File

@@ -104,7 +104,7 @@ class PinnedEventsTimelineProvider @Inject constructor(
is AsyncData.Uninitialized, is AsyncData.Failure -> {
timelineStateFlow.emit(AsyncData.Loading())
withContext(dispatchers.io) {
room.pinnedEventsTimeline()
room.createTimeline(onlyPinnedEvents = true)
}
.fold(
{ timelineStateFlow.emit(AsyncData.Success(it)) },

View File

@@ -64,7 +64,7 @@ class TimelineController @Inject constructor(
}
suspend fun focusOnEvent(eventId: EventId): Result<Unit> {
return room.timelineFocusedOnEvent(eventId)
return room.createTimeline(focusedOnEventId = eventId)
.onFailure {
if (it is CancellationException) {
throw it

View File

@@ -54,11 +54,11 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureCalledOnceWithParam
import io.element.android.tests.testutils.EnsureCalledOnceWithTwoParamsAndResult
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EnsureNeverCalledWithParamAndResult
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParamsAndResult
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
@@ -129,8 +129,9 @@ class MessagesViewTest {
eventSink = eventsRecorder
)
val timelineItem = state.timelineState.timelineItems.first()
val callback = EnsureCalledOnceWithParam(
expectedParam = timelineItem,
val callback = EnsureCalledOnceWithTwoParamsAndResult(
expectedParam1 = true,
expectedParam2 = timelineItem,
result = true,
)
rule.setMessagesView(
@@ -513,7 +514,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
state: MessagesState,
onBackClick: () -> Unit = EnsureNeverCalled(),
onRoomDetailsClick: () -> Unit = EnsureNeverCalled(),
onEventClick: (event: TimelineItem.Event) -> Boolean = EnsureNeverCalledWithParamAndResult(),
onEventClick: (isLive: Boolean, event: TimelineItem.Event) -> Boolean = EnsureNeverCalledWithTwoParamsAndResult(),
onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(),
onLinkClick: (String, Boolean) -> Unit = EnsureNeverCalledWithTwoParams(),
onSendLocationClick: () -> Unit = EnsureNeverCalled(),

View File

@@ -55,7 +55,7 @@ class PinnedMessagesBannerPresenterTest {
@Test
fun `present - loading state`() = runTest {
val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.success(FakeTimeline()) }
createTimelineResult = { _, _, _ -> Result.success(FakeTimeline()) }
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
}
@@ -86,7 +86,7 @@ class PinnedMessagesBannerPresenterTest {
)
)
val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) }
createTimelineResult = { _, _, _ -> Result.success(pinnedEventsTimeline) }
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID, AN_EVENT_ID_2)))
}
@@ -125,7 +125,7 @@ class PinnedMessagesBannerPresenterTest {
)
)
val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) }
createTimelineResult = { _, _, _ -> Result.success(pinnedEventsTimeline) }
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID, AN_EVENT_ID_2)))
}
@@ -160,7 +160,7 @@ class PinnedMessagesBannerPresenterTest {
@Test
fun `present - timeline failed`() = runTest {
val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.failure(Exception()) }
createTimelineResult = { _, _, _ -> Result.failure(Exception()) }
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
}

View File

@@ -83,7 +83,7 @@ class PinnedMessagesListPresenterTest {
@Test
fun `present - timeline failure state`() = runTest {
val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.failure(RuntimeException()) },
createTimelineResult = { _, _, _ -> Result.failure(RuntimeException()) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
@@ -102,7 +102,7 @@ class PinnedMessagesListPresenterTest {
@Test
fun `present - empty state`() = runTest {
val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.success(FakeTimeline()) },
createTimelineResult = { _, _, _ -> Result.success(FakeTimeline()) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
@@ -122,7 +122,7 @@ class PinnedMessagesListPresenterTest {
fun `present - filled state`() = runTest {
val pinnedEventsTimeline = createPinnedMessagesTimeline()
val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) },
createTimelineResult = { _, _, _ -> Result.success(pinnedEventsTimeline) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
@@ -149,7 +149,7 @@ class PinnedMessagesListPresenterTest {
val pinnedEventsTimeline = createPinnedMessagesTimeline()
val analyticsService = FakeAnalyticsService()
val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) },
createTimelineResult = { _, _, _ -> Result.success(pinnedEventsTimeline) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
@@ -195,7 +195,7 @@ class PinnedMessagesListPresenterTest {
}
val pinnedEventsTimeline = createPinnedMessagesTimeline()
val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) },
createTimelineResult = { _, _, _ -> Result.success(pinnedEventsTimeline) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
@@ -224,7 +224,7 @@ class PinnedMessagesListPresenterTest {
}
val pinnedEventsTimeline = createPinnedMessagesTimeline()
val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) },
createTimelineResult = { _, _, _ -> Result.success(pinnedEventsTimeline) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
@@ -253,7 +253,7 @@ class PinnedMessagesListPresenterTest {
}
val pinnedEventsTimeline = createPinnedMessagesTimeline()
val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) },
createTimelineResult = { _, _, _ -> Result.success(pinnedEventsTimeline) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },

View File

@@ -31,7 +31,7 @@ class TimelineControllerTest {
val detachedTimeline = FakeTimeline(name = "detached")
val matrixRoom = FakeMatrixRoom(
liveTimeline = liveTimeline,
timelineFocusedOnEventResult = { Result.success(detachedTimeline) }
createTimelineResult = { _, _, _ -> Result.success(detachedTimeline) }
)
val sut = TimelineController(matrixRoom)
@@ -63,7 +63,7 @@ class TimelineControllerTest {
var callNumber = 0
val matrixRoom = FakeMatrixRoom(
liveTimeline = liveTimeline,
timelineFocusedOnEventResult = {
createTimelineResult = { _, _, _ ->
callNumber++
when (callNumber) {
1 -> Result.success(detachedTimeline1)
@@ -117,7 +117,7 @@ class TimelineControllerTest {
val detachedTimeline = FakeTimeline(name = "detached")
val matrixRoom = FakeMatrixRoom(
liveTimeline = liveTimeline,
timelineFocusedOnEventResult = { Result.success(detachedTimeline) }
createTimelineResult = { _, _, _ -> Result.success(detachedTimeline) }
)
val sut = TimelineController(matrixRoom)
sut.activeTimelineFlow().test {
@@ -167,7 +167,7 @@ class TimelineControllerTest {
}
val matrixRoom = FakeMatrixRoom(
liveTimeline = liveTimeline,
timelineFocusedOnEventResult = { Result.success(detachedTimeline) }
createTimelineResult = { _, _, _ -> Result.success(detachedTimeline) }
)
val sut = TimelineController(matrixRoom)
sut.activeTimelineFlow().test {
@@ -192,7 +192,7 @@ class TimelineControllerTest {
val detachedTimeline = FakeTimeline(name = "detached")
val matrixRoom = FakeMatrixRoom(
liveTimeline = liveTimeline,
timelineFocusedOnEventResult = { Result.success(detachedTimeline) }
createTimelineResult = { _, _, _ -> Result.success(detachedTimeline) }
)
val sut = TimelineController(matrixRoom)

View File

@@ -483,7 +483,7 @@ import kotlin.time.Duration.Companion.seconds
)
val room = FakeMatrixRoom(
liveTimeline = liveTimeline,
timelineFocusedOnEventResult = { Result.success(detachedTimeline) },
createTimelineResult = { _, _, _ -> Result.success(detachedTimeline) },
canUserSendMessageResult = { _, _ -> Result.success(true) },
)
val presenter = createTimelinePresenter(
@@ -561,7 +561,7 @@ import kotlin.time.Duration.Companion.seconds
liveTimeline = FakeTimeline(
timelineItems = flowOf(emptyList()),
),
timelineFocusedOnEventResult = { Result.failure(Throwable("An error")) },
createTimelineResult = { _, _, _ -> Result.failure(Throwable("An error")) },
canUserSendMessageResult = { _, _ -> Result.success(true) },
)
)

View File

@@ -109,21 +109,17 @@ interface MatrixRoom : Closeable {
val liveTimeline: Timeline
/**
* Create a new timeline, focused on the provided Event.
* Should not be used directly, see `TimelineController` to manage the various timelines.
* Create a new timeline.
* @param focusedOnEventId The event to focus on, if any. Note: if not null, and for regular timeline,
* this method should not be used directly, see `TimelineController` to manage the various timelines.
* @param onlyPinnedEvents True to get the timeline for pinned events only.
* @param onlyMedia True to get the timeline for media events only.
*/
suspend fun timelineFocusedOnEvent(eventId: EventId): Result<Timeline>
/**
* Create a new timeline for the pinned events of the room.
*/
suspend fun pinnedEventsTimeline(): Result<Timeline>
/**
* Create a new timeline for the media events of the room.
* @param eventId The event to focus on, if any.
*/
suspend fun mediaTimeline(eventId: EventId?): Result<Timeline>
suspend fun createTimeline(
focusedOnEventId: EventId? = null,
onlyPinnedEvents: Boolean = false,
onlyMedia: Boolean = false,
): Result<Timeline>
fun destroy()

View File

@@ -214,80 +214,72 @@ class RustMatrixRoom(
override suspend fun subscribeToSync() = roomSyncSubscriber.subscribe(roomId)
override suspend fun timelineFocusedOnEvent(eventId: EventId): Result<Timeline> = withContext(roomDispatcher) {
runCatching {
innerRoom.timelineWithConfiguration(
configuration = TimelineConfiguration(
focus = TimelineFocus.Event(
eventId = eventId.value,
numContextEvents = 50u,
),
allowedMessageTypes = AllowedMessageTypes.All,
internalIdPrefix = "focus_$eventId",
dateDividerMode = DateDividerMode.DAILY,
)
).let { inner ->
createTimeline(inner, mode = Timeline.Mode.FOCUSED_ON_EVENT)
}
}.mapFailure {
it.toFocusEventException()
}.onFailure {
if (it is CancellationException) {
throw it
}
}
}
override suspend fun pinnedEventsTimeline(): Result<Timeline> = withContext(roomDispatcher) {
runCatching {
innerRoom.timelineWithConfiguration(
configuration = TimelineConfiguration(
focus = TimelineFocus.PinnedEvents(
maxEventsToLoad = 100u,
maxConcurrentRequests = 10u,
),
allowedMessageTypes = AllowedMessageTypes.All,
internalIdPrefix = "pinned_events",
dateDividerMode = DateDividerMode.DAILY,
)
).let { inner ->
createTimeline(inner, mode = Timeline.Mode.PINNED_EVENTS)
}
}.onFailure {
if (it is CancellationException) {
throw it
}
}
}
override suspend fun mediaTimeline(
eventId: EventId?,
override suspend fun createTimeline(
focusedOnEventId: EventId?,
onlyPinnedEvents: Boolean,
onlyMedia: Boolean,
): Result<Timeline> = withContext(roomDispatcher) {
val focus = if (eventId != null) {
val focus = if (onlyPinnedEvents) {
TimelineFocus.PinnedEvents(
maxEventsToLoad = 100u,
maxConcurrentRequests = 10u,
)
} else if (focusedOnEventId != null) {
TimelineFocus.Event(
eventId = eventId.value,
eventId = focusedOnEventId.value,
numContextEvents = 50u,
)
} else {
TimelineFocus.Live
}
val allowedMessageTypes = if (onlyMedia) {
AllowedMessageTypes.Only(
types = listOf(
RoomMessageEventMessageType.FILE,
RoomMessageEventMessageType.IMAGE,
RoomMessageEventMessageType.VIDEO,
RoomMessageEventMessageType.AUDIO,
)
)
} else {
AllowedMessageTypes.All
}
val internalIdPrefix = if (onlyPinnedEvents) {
"pinned_events"
} else if (focusedOnEventId != null) {
"focus_$focusedOnEventId"
} else if (onlyMedia) {
"MediaGallery_"
} else {
"live"
}
val dateDividerMode = if (onlyMedia) {
DateDividerMode.MONTHLY
} else {
DateDividerMode.DAILY
}
val mode = when {
onlyPinnedEvents -> Timeline.Mode.PINNED_EVENTS
focusedOnEventId != null -> Timeline.Mode.FOCUSED_ON_EVENT
onlyMedia -> Timeline.Mode.MEDIA
else -> Timeline.Mode.LIVE
}
runCatching {
innerRoom.timelineWithConfiguration(
configuration = TimelineConfiguration(
focus = focus,
allowedMessageTypes = AllowedMessageTypes.Only(
types = listOf(
RoomMessageEventMessageType.FILE,
RoomMessageEventMessageType.IMAGE,
RoomMessageEventMessageType.VIDEO,
RoomMessageEventMessageType.AUDIO,
)
),
internalIdPrefix = "MediaGallery_",
dateDividerMode = DateDividerMode.MONTHLY,
allowedMessageTypes = allowedMessageTypes,
internalIdPrefix = internalIdPrefix,
dateDividerMode = dateDividerMode,
)
).let { inner ->
createTimeline(inner, mode = if (eventId != null) Timeline.Mode.FOCUSED_ON_EVENT else Timeline.Mode.MEDIA)
createTimeline(inner, mode = mode)
}
}.mapFailure {
if (focusedOnEventId != null) {
it.toFocusEventException()
} else {
it
}
}.onFailure {
if (it is CancellationException) {

View File

@@ -138,9 +138,7 @@ class FakeMatrixRoom(
private val leaveRoomLambda: () -> Result<Unit> = { lambdaError() },
private val updateMembersResult: () -> Unit = { lambdaError() },
private val getMembersResult: (Int) -> Result<List<RoomMember>> = { lambdaError() },
private val timelineFocusedOnEventResult: (EventId) -> Result<Timeline> = { lambdaError() },
private val pinnedEventsTimelineResult: () -> Result<Timeline> = { lambdaError() },
private val mediaTimelineResult: (EventId?) -> Result<Timeline> = { lambdaError() },
private val createTimelineResult: (EventId?, Boolean, Boolean) -> Result<Timeline> = { _, _, _ -> lambdaError() },
private val setSendQueueEnabledLambda: (Boolean) -> Unit = { _: Boolean -> },
private val saveComposerDraftLambda: (ComposerDraft) -> Result<Unit> = { _: ComposerDraft -> Result.success(Unit) },
private val loadComposerDraftLambda: () -> Result<ComposerDraft?> = { Result.success<ComposerDraft?>(null) },
@@ -220,16 +218,12 @@ class FakeMatrixRoom(
_syncUpdateFlow.tryEmit(_syncUpdateFlow.value + 1)
}
override suspend fun timelineFocusedOnEvent(eventId: EventId): Result<Timeline> = simulateLongTask {
timelineFocusedOnEventResult(eventId)
}
override suspend fun pinnedEventsTimeline(): Result<Timeline> = simulateLongTask {
pinnedEventsTimelineResult()
}
override suspend fun mediaTimeline(eventId: EventId?): Result<Timeline> = simulateLongTask {
mediaTimelineResult(eventId)
override suspend fun createTimeline(
focusedOnEventId: EventId?,
onlyPinnedEvents: Boolean,
onlyMedia: Boolean,
): Result<Timeline> = simulateLongTask {
createTimelineResult(focusedOnEventId, onlyPinnedEvents, onlyMedia)
}
override suspend fun subscribeToSync() {

View File

@@ -7,6 +7,7 @@
package io.element.android.libraries.mediaviewer.api
import android.os.Parcelable
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
@@ -14,6 +15,8 @@ import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.Timeline
import kotlinx.parcelize.Parcelize
interface MediaViewerEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
@@ -39,9 +42,14 @@ interface MediaViewerEntryPoint : FeatureEntryPoint {
val canShowInfo: Boolean,
) : NodeInputs
enum class MediaViewerMode {
SingleMedia,
TimelineImagesAndVideos,
TimelineFilesAndAudios,
sealed interface MediaViewerMode : Parcelable {
@Parcelize
data object SingleMedia : MediaViewerMode
@Parcelize
data class TimelineImagesAndVideos(val timelineMode: Timeline.Mode) : MediaViewerMode
@Parcelize
data class TimelineFilesAndAudios(val timelineMode: Timeline.Mode) : MediaViewerMode
}
}

View File

@@ -18,6 +18,7 @@ interface FocusedTimelineMediaGalleryDataSourceFactory {
fun createFor(
eventId: EventId,
mediaItem: MediaItem.Event,
onlyPinnedEvents: Boolean,
): MediaGalleryDataSource
}
@@ -30,6 +31,7 @@ class DefaultFocusedTimelineMediaGalleryDataSourceFactory @Inject constructor(
override fun createFor(
eventId: EventId,
mediaItem: MediaItem.Event,
onlyPinnedEvents: Boolean,
): MediaGalleryDataSource {
return TimelineMediaGalleryDataSource(
room = room,
@@ -37,6 +39,7 @@ class DefaultFocusedTimelineMediaGalleryDataSourceFactory @Inject constructor(
room = room,
eventId = eventId,
initialMediaItem = mediaItem,
onlyPinnedEvents = onlyPinnedEvents,
),
timelineMediaItemsFactory = timelineMediaItemsFactory,
mediaItemsPostProcessor = mediaItemsPostProcessor,

View File

@@ -44,7 +44,7 @@ class LiveMediaTimeline @Inject constructor(
override suspend fun getTimeline(): Result<Timeline> = mutex.withLock {
val currentTimeline = timeline
if (currentTimeline == null) {
room.mediaTimeline(null)
room.createTimeline(onlyMedia = true)
.onSuccess { timeline = it }
} else {
Result.success(currentTimeline)
@@ -58,14 +58,20 @@ class LiveMediaTimeline @Inject constructor(
/**
* A class that will provide a media timeline that is focused on a particular event.
* Optionally, the timeline will only contain the pinned events.
*/
class FocusedMediaTimeline(
private val room: MatrixRoom,
private val eventId: EventId,
private val onlyPinnedEvents: Boolean,
initialMediaItem: MediaItem.Event,
) : MediaTimeline {
override suspend fun getTimeline(): Result<Timeline> {
return room.mediaTimeline(eventId)
return room.createTimeline(
focusedOnEventId = eventId,
onlyPinnedEvents = onlyPinnedEvents,
onlyMedia = true,
)
}
override val cache = persistentListOf(

View File

@@ -27,6 +27,7 @@ import io.element.android.libraries.architecture.overlay.operation.show
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaviewer.api.MediaGalleryEntryPoint
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
@@ -96,9 +97,9 @@ class MediaGalleryRootNode @AssistedInject constructor(
val mode = when (item) {
is MediaItem.Audio,
is MediaItem.Voice,
is MediaItem.File -> MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios
is MediaItem.File -> MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(Timeline.Mode.MEDIA)
is MediaItem.Image,
is MediaItem.Video -> MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos
is MediaItem.Video -> MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.MEDIA)
}
overlay.show(
NavTarget.MediaViewer(

View File

@@ -52,8 +52,8 @@ class MediaViewerDataSource(
private val galleryMode = when (mode) {
MediaViewerMode.SingleMedia,
MediaViewerMode.TimelineImagesAndVideos -> MediaGalleryMode.Images
MediaViewerMode.TimelineFilesAndAudios -> MediaGalleryMode.Files
is MediaViewerMode.TimelineImagesAndVideos -> MediaGalleryMode.Images
is MediaViewerMode.TimelineFilesAndAudios -> MediaGalleryMode.Files
}
// Map of sourceUrl to local media state

View File

@@ -22,6 +22,7 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
import io.element.android.libraries.mediaviewer.impl.datasource.FocusedTimelineMediaGalleryDataSourceFactory
@@ -69,16 +70,40 @@ class MediaViewerNode @AssistedInject constructor(
// Should not happen
timelineMediaGalleryDataSource
} else {
// Does timelineMediaGalleryDataSource knows the eventId?
val lastData = timelineMediaGalleryDataSource.getLastData().dataOrNull()
val isEventKnown = lastData?.hasEvent(eventId) == true
if (isEventKnown) {
timelineMediaGalleryDataSource
} else {
focusedTimelineMediaGalleryDataSourceFactory.createFor(
eventId = eventId,
mediaItem = inputs.toMediaItem(),
)
// Can we use a specific timeline?
val timelineMode = when (val mode = inputs.mode) {
is MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> mode.timelineMode
is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> mode.timelineMode
else -> null
}
when (timelineMode) {
null -> timelineMediaGalleryDataSource
Timeline.Mode.LIVE -> {
// Even if the timelineMediaGalleryDataSource does not know the eventId, the SDK will create the timeline faster
timelineMediaGalleryDataSource
}
Timeline.Mode.FOCUSED_ON_EVENT -> {
// Does timelineMediaGalleryDataSource knows the eventId?
val lastData = timelineMediaGalleryDataSource.getLastData().dataOrNull()
val isEventKnown = lastData?.hasEvent(eventId) == true
if (isEventKnown) {
timelineMediaGalleryDataSource
} else {
focusedTimelineMediaGalleryDataSourceFactory.createFor(
eventId = eventId,
mediaItem = inputs.toMediaItem(),
onlyPinnedEvents = false,
)
}
}
Timeline.Mode.PINNED_EVENTS -> {
focusedTimelineMediaGalleryDataSourceFactory.createFor(
eventId = eventId,
mediaItem = inputs.toMediaItem(),
onlyPinnedEvents = true,
)
}
Timeline.Mode.MEDIA -> timelineMediaGalleryDataSource
}
}
}

View File

@@ -204,8 +204,8 @@ class MediaViewerPresenter @AssistedInject constructor(
private fun showNoMoreItemsSnackbar() {
val messageResId = when (inputs.mode) {
MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> R.string.screen_media_details_no_more_media_to_show
MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> R.string.screen_media_details_no_more_files_to_show
is MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> R.string.screen_media_details_no_more_media_to_show
is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> R.string.screen_media_details_no_more_files_to_show
}
val message = SnackbarMessage(messageResId)
snackbarDispatcher.post(message)

View File

@@ -25,6 +25,7 @@ class DefaultFocusedTimelineMediaGalleryDataSourceFactoryTest {
val result = sut.createFor(
eventId = AN_EVENT_ID,
mediaItem = aMediaItemImage(),
onlyPinnedEvents = false,
)
assertThat(result).isInstanceOf(TimelineMediaGalleryDataSource::class.java)
}

View File

@@ -75,11 +75,11 @@ class FocusedMediaTimelineTest {
@Test
fun `getTimeline returns the timeline provided by the room`() = runTest {
val mediaTimelineResult = lambdaRecorder<EventId?, Result<Timeline>> {
val createTimelineResult = lambdaRecorder<EventId?, Boolean, Boolean, Result<Timeline>> { _, _, _ ->
Result.success(FakeTimeline())
}
val room = FakeMatrixRoom(
mediaTimelineResult = mediaTimelineResult,
createTimelineResult = createTimelineResult,
)
val sut = createFocusedMediaTimeline(
room = room,
@@ -87,16 +87,38 @@ class FocusedMediaTimelineTest {
)
val timeline = sut.getTimeline()
assertThat(timeline.isSuccess).isTrue()
mediaTimelineResult.assertions().isCalledOnce().with(value(AN_EVENT_ID))
createTimelineResult.assertions().isCalledOnce()
.with(value(AN_EVENT_ID), value(false), value(true))
}
@Test
fun `getTimeline returns the timeline provided by the room for pinned Events`() = runTest {
val createTimelineResult = lambdaRecorder<EventId?, Boolean, Boolean, Result<Timeline>> { _, _, _ ->
Result.success(FakeTimeline())
}
val room = FakeMatrixRoom(
createTimelineResult = createTimelineResult,
)
val sut = createFocusedMediaTimeline(
room = room,
eventId = AN_EVENT_ID,
onlyPinnedEvent = true,
)
val timeline = sut.getTimeline()
assertThat(timeline.isSuccess).isTrue()
createTimelineResult.assertions().isCalledOnce()
.with(value(AN_EVENT_ID), value(true), value(true))
}
private fun createFocusedMediaTimeline(
room: MatrixRoom = FakeMatrixRoom(),
eventId: EventId = AN_EVENT_ID,
initialMediaItem: MediaItem.Event = aMediaItemImage(),
onlyPinnedEvent: Boolean = false,
) = FocusedMediaTimeline(
room = room,
eventId = eventId,
initialMediaItem = initialMediaItem,
onlyPinnedEvents = onlyPinnedEvent,
)
}

View File

@@ -28,22 +28,23 @@ class LiveMediaTimelineTest {
@Test
fun `getTimeline returns the timeline provided by the room, then from cache`() = runTest {
val mediaTimelineResult = lambdaRecorder<EventId?, Result<Timeline>> {
val createTimelineResult = lambdaRecorder<EventId?, Boolean, Boolean, Result<Timeline>> { _, _, _ ->
Result.success(FakeTimeline())
}
val room = FakeMatrixRoom(
mediaTimelineResult = mediaTimelineResult,
createTimelineResult = createTimelineResult,
)
val sut = createLiveMediaTimeline(
room = room,
)
val timeline = sut.getTimeline()
assertThat(timeline.isSuccess).isTrue()
mediaTimelineResult.assertions().isCalledOnce().with(value(null))
createTimelineResult.assertions().isCalledOnce()
.with(value(null), value(false), value(true))
val timeline2 = sut.getTimeline()
assertThat(timeline2.isSuccess).isTrue()
// No called another time
mediaTimelineResult.assertions().isCalledOnce()
createTimelineResult.assertions().isCalledOnce()
}
private fun createLiveMediaTimeline(

View File

@@ -57,7 +57,7 @@ class TimelineMediaGalleryDataSourceTest {
val fakeTimeline = FakeTimeline()
val sut = createTimelineMediaGalleryDataSource(
room = FakeMatrixRoom(
mediaTimelineResult = { Result.success(fakeTimeline) },
createTimelineResult = { _, _, _ -> Result.success(fakeTimeline) },
roomCoroutineScope = backgroundScope,
)
)
@@ -75,7 +75,7 @@ class TimelineMediaGalleryDataSourceTest {
runTest {
val sut = createTimelineMediaGalleryDataSource(
room = FakeMatrixRoom(
mediaTimelineResult = { Result.success(fakeTimeline) },
createTimelineResult = { _, _, _ -> Result.success(fakeTimeline) },
roomCoroutineScope = backgroundScope,
)
)
@@ -112,7 +112,7 @@ class TimelineMediaGalleryDataSourceTest {
}
val sut = createTimelineMediaGalleryDataSource(
room = FakeMatrixRoom(
mediaTimelineResult = { Result.success(fakeTimeline) },
createTimelineResult = { _, _, _ -> Result.success(fakeTimeline) },
roomCoroutineScope = backgroundScope,
)
)
@@ -135,7 +135,7 @@ class TimelineMediaGalleryDataSourceTest {
}
val sut = createTimelineMediaGalleryDataSource(
room = FakeMatrixRoom(
mediaTimelineResult = { Result.success(fakeTimeline) },
createTimelineResult = { _, _, _ -> Result.success(fakeTimeline) },
roomCoroutineScope = backgroundScope,
)
)
@@ -154,7 +154,7 @@ class TimelineMediaGalleryDataSourceTest {
fun `test - failing to load timeline should emit an error`() = runTest {
val sut = createTimelineMediaGalleryDataSource(
room = FakeMatrixRoom(
mediaTimelineResult = { Result.failure(AN_EXCEPTION) },
createTimelineResult = { _, _, _ -> Result.failure(AN_EXCEPTION) },
roomCoroutineScope = backgroundScope,
)
)
@@ -176,7 +176,7 @@ class TimelineMediaGalleryDataSourceTest {
)
val sut = createTimelineMediaGalleryDataSource(
room = FakeMatrixRoom(
mediaTimelineResult = { Result.success(fakeTimeline) },
createTimelineResult = { _, _, _ -> Result.success(fakeTimeline) },
roomCoroutineScope = backgroundScope,
)
)

View File

@@ -52,7 +52,7 @@ class MediaGalleryPresenterTest {
),
room = FakeMatrixRoom(
displayName = A_ROOM_NAME,
mediaTimelineResult = { Result.success(FakeTimeline()) },
createTimelineResult = { _, _, _ -> Result.success(FakeTimeline()) },
)
)
presenter.test {
@@ -71,7 +71,7 @@ class MediaGalleryPresenterTest {
val presenter = createMediaGalleryPresenter(
room = FakeMatrixRoom(
displayName = A_ROOM_NAME,
mediaTimelineResult = { Result.success(FakeTimeline()) },
createTimelineResult = { _, _, _ -> Result.success(FakeTimeline()) },
)
)
presenter.test {
@@ -101,7 +101,7 @@ class MediaGalleryPresenterTest {
room = FakeMatrixRoom(
sessionId = A_USER_ID,
displayName = A_ROOM_NAME,
mediaTimelineResult = { Result.success(FakeTimeline()) },
createTimelineResult = { _, _, _ -> Result.success(FakeTimeline()) },
canRedactOwnResult = { Result.success(canDeleteOwn) }
)
)
@@ -144,7 +144,7 @@ class MediaGalleryPresenterTest {
room = FakeMatrixRoom(
sessionId = A_USER_ID,
displayName = A_ROOM_NAME,
mediaTimelineResult = { Result.success(FakeTimeline()) },
createTimelineResult = { _, _, _ -> Result.success(FakeTimeline()) },
canRedactOtherResult = { Result.success(canDeleteOther) }
)
)
@@ -177,7 +177,7 @@ class MediaGalleryPresenterTest {
val presenter = createMediaGalleryPresenter(
room = FakeMatrixRoom(
displayName = A_ROOM_NAME,
mediaTimelineResult = { Result.success(FakeTimeline()) },
createTimelineResult = { _, _, _ -> Result.success(FakeTimeline()) },
)
)
presenter.test {
@@ -244,7 +244,7 @@ class MediaGalleryPresenterTest {
)
val presenter = createMediaGalleryPresenter(
room = FakeMatrixRoom(
mediaTimelineResult = { Result.success(FakeTimeline()) },
createTimelineResult = { _, _, _ -> Result.success(FakeTimeline()) },
),
navigator = navigator,
)

View File

@@ -137,7 +137,7 @@ class MediaViewerDataSourceTest {
fun `test dataFlow with data galleryMode image`() = runTest {
val galleryDataSource = FakeMediaGalleryDataSource()
val sut = createMediaViewerDataSource(
mode = MediaViewerMode.TimelineImagesAndVideos,
mode = MediaViewerMode.TimelineImagesAndVideos(timelineMode = Timeline.Mode.MEDIA),
galleryDataSource = galleryDataSource,
)
sut.dataFlow().test {
@@ -159,7 +159,7 @@ class MediaViewerDataSourceTest {
fun `test dataFlow with data galleryMode files`() = runTest {
val galleryDataSource = FakeMediaGalleryDataSource()
val sut = createMediaViewerDataSource(
mode = MediaViewerMode.TimelineFilesAndAudios,
mode = MediaViewerMode.TimelineFilesAndAudios(timelineMode = Timeline.Mode.MEDIA),
galleryDataSource = galleryDataSource,
)
sut.dataFlow().test {
@@ -265,7 +265,7 @@ class MediaViewerDataSourceTest {
}
private fun TestScope.createMediaViewerDataSource(
mode: MediaViewerMode = MediaViewerMode.TimelineImagesAndVideos,
mode: MediaViewerMode = MediaViewerMode.TimelineImagesAndVideos(timelineMode = Timeline.Mode.MEDIA),
galleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource(),
mediaLoader: MatrixMediaLoader = FakeMatrixMediaLoader(),
localMediaFactory: LocalMediaFactory = FakeLocalMediaFactory(mockMediaUrl),

View File

@@ -519,7 +519,7 @@ class MediaViewerPresenterTest {
@Test
fun `present - snackbar displayed when there is no more items forward images and videos`() {
`present - snackbar displayed when there is no more items forward`(
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos,
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(timelineMode = Timeline.Mode.MEDIA),
expectedSnackbarResId = R.string.screen_media_details_no_more_media_to_show,
)
}
@@ -527,7 +527,7 @@ class MediaViewerPresenterTest {
@Test
fun `present - snackbar displayed when there is no more items forward files and audio`() {
`present - snackbar displayed when there is no more items forward`(
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios,
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(timelineMode = Timeline.Mode.MEDIA),
expectedSnackbarResId = R.string.screen_media_details_no_more_files_to_show,
)
}
@@ -547,7 +547,7 @@ class MediaViewerPresenterTest {
awaitFirstItem()
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
if (mode == MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) {
if (mode is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) {
GroupedMediaItems(
imageAndVideoItems = persistentListOf(),
fileItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator),
@@ -568,7 +568,7 @@ class MediaViewerPresenterTest {
// data source claims that there is no more items to load forward
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
if (mode == MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) {
if (mode is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) {
GroupedMediaItems(
imageAndVideoItems = persistentListOf(),
fileItems = persistentListOf(anImage, aBackwardLoadingIndicator),
@@ -590,7 +590,7 @@ class MediaViewerPresenterTest {
@Test
fun `present - snackbar displayed when there is no more items backward images and videos`() {
`present - snackbar displayed when there is no more items backward`(
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos,
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(timelineMode = Timeline.Mode.MEDIA),
expectedSnackbarResId = R.string.screen_media_details_no_more_media_to_show,
)
}
@@ -598,7 +598,7 @@ class MediaViewerPresenterTest {
@Test
fun `present - snackbar displayed when there is no more items backward files and audio`() {
`present - snackbar displayed when there is no more items backward`(
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios,
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(timelineMode = Timeline.Mode.MEDIA),
expectedSnackbarResId = R.string.screen_media_details_no_more_files_to_show,
)
}
@@ -618,7 +618,7 @@ class MediaViewerPresenterTest {
awaitFirstItem()
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
if (mode == MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) {
if (mode is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) {
GroupedMediaItems(
imageAndVideoItems = persistentListOf(),
fileItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator),
@@ -640,7 +640,7 @@ class MediaViewerPresenterTest {
// data source claims that there is no more items to load backward
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
if (mode == MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) {
if (mode is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) {
GroupedMediaItems(
imageAndVideoItems = persistentListOf(),
fileItems = persistentListOf(aForwardLoadingIndicator, anImage),

View File

@@ -84,6 +84,27 @@ class EnsureCalledOnceWithTwoParams<T, U>(
}
}
class EnsureCalledOnceWithTwoParamsAndResult<T, U, R>(
private val expectedParam1: T,
private val expectedParam2: U,
private val result: R,
) : (T, U) -> R {
private var counter = 0
override fun invoke(p1: T, p2: U): R {
if (p1 != expectedParam1 || p2 != expectedParam2) {
throw AssertionError("Expected to be called with $expectedParam1 and $expectedParam2, but was called with $p1 and $p2")
}
counter++
return result
}
fun assertSuccess() {
if (counter != 1) {
throw AssertionError("Expected to be called once, but was called $counter times")
}
}
}
/**
* Shortcut for [<T, R> ensureCalledOnceWithParam] with Unit result.
*/

View File

@@ -32,3 +32,9 @@ class EnsureNeverCalledWithTwoParams<T, U> : (T, U) -> Unit {
lambdaError("Should not be called and is called with $p1 and $p2")
}
}
class EnsureNeverCalledWithTwoParamsAndResult<T, U, R> : (T, U) -> R {
override fun invoke(p1: T, p2: U): R {
lambdaError("Should not be called and is called with $p1 and $p2")
}
}