From 52ec53575af9073eb381eb73a28e2fa792db958d Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 11 Jul 2023 12:50:50 +0200 Subject: [PATCH 01/42] Timeline: improve "jump to bottom" button --- .../messages/impl/timeline/TimelineView.kt | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index bb33981a64..a8da9d5543 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -21,6 +21,7 @@ package io.element.android.features.messages.impl.timeline import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.tween import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.foundation.layout.Box @@ -278,8 +279,8 @@ internal fun BoxScope.TimelineScrollHelper( .align(Alignment.BottomEnd) .padding(end = 24.dp, bottom = 12.dp), visible = showScrollToBottomButton || LocalInspectionMode.current, - enter = scaleIn(), - exit = scaleOut(), + enter = scaleIn(animationSpec = tween(100)), + exit = scaleOut(animationSpec = tween(100)), ) { FloatingActionButton( onClick = { @@ -293,14 +294,7 @@ internal fun BoxScope.TimelineScrollHelper( }, elevation = FloatingActionButtonDefaults.elevation(4.dp, 4.dp, 4.dp, 4.dp), shape = CircleShape, - modifier = Modifier - .shadow( - elevation = 4.dp, - shape = CircleShape, - ambientColor = ElementTheme.materialColors.primary, - spotColor = ElementTheme.materialColors.primary, - ) - .size(36.dp), + modifier = Modifier.size(36.dp), containerColor = ElementTheme.colors.bgSubtleSecondary, contentColor = ElementTheme.colors.iconSecondary ) { From 3524633f5d014df80889c1b5405baf143b24645c Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 11 Jul 2023 12:51:01 +0200 Subject: [PATCH 02/42] Timeline: remove duplicated code --- .../features/messages/impl/timeline/TimelineView.kt | 7 ------- 1 file changed, 7 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index a8da9d5543..4e0ca6124b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -101,13 +101,6 @@ fun TimelineView( // TODO implement this logic once we have support to 'jump to event X' in sliding sync } - // Send an event to the presenter when the scrolling is finished, with the first visible index at the bottom. - val firstVisibleIndex by remember { derivedStateOf { lazyListState.firstVisibleItemIndex } } - val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } } - LaunchedEffect(firstVisibleIndex, isScrollFinished) { - if (!isScrollFinished) return@LaunchedEffect - state.eventSink(TimelineEvents.OnScrollFinished(firstVisibleIndex)) - } Box(modifier = modifier) { LazyColumn( From 771a4ecdd3d3da85c6bb3acdbec03924e64c8ae7 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 12 Jul 2023 18:15:17 +0200 Subject: [PATCH 03/42] Create value class for transactionId. There is no validation on the format, but validation is a bonus for userId, roomId, etc. The main advantage of using value classes instead of Strings everywhere is to detect errors at compilation time. --- .../impl/timeline/TimelineStateProvider.kt | 3 ++- .../impl/timeline/model/TimelineItem.kt | 3 ++- .../MessageComposerPresenterTest.kt | 3 ++- .../matrix/api/core/TransactionId.kt | 24 +++++++++++++++++++ .../libraries/matrix/api/room/MatrixRoom.kt | 7 +++--- .../matrix/api/timeline/MatrixTimelineItem.kt | 3 ++- .../timeline/item/event/EventTimelineItem.kt | 3 ++- .../matrix/impl/room/RustMatrixRoom.kt | 13 +++++----- .../item/event/EventTimelineItemMapper.kt | 3 ++- .../android/libraries/matrix/test/TestData.kt | 3 ++- .../matrix/test/room/FakeMatrixRoom.kt | 7 +++--- .../matrix/test/room/RoomSummaryFixture.kt | 3 ++- .../textcomposer/MessageComposerMode.kt | 3 ++- .../libraries/textcomposer/TextComposer.kt | 3 ++- 14 files changed, 59 insertions(+), 22 deletions(-) create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/TransactionId.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt index e96d7ac0f3..a02b1cf78b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt @@ -27,6 +27,7 @@ import io.element.android.features.messages.impl.timeline.model.virtual.aTimelin import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.TransactionId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo @@ -101,7 +102,7 @@ fun aTimelineItemDaySeparator(): TimelineItem.Virtual { internal fun aTimelineItemEvent( eventId: EventId = EventId("\$" + Random.nextInt().toString()), - transactionId: String? = null, + transactionId: TransactionId? = null, isMine: Boolean = false, content: TimelineItemEventContent = aTimelineItemTextContent(), groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt index 6743651e76..36f700c7dd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt @@ -22,6 +22,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel import io.element.android.libraries.designsystem.components.avatar.AvatarData 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.core.UserId import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState @@ -53,7 +54,7 @@ sealed interface TimelineItem { data class Event( val id: String, val eventId: EventId? = null, - val transactionId: String? = null, + val transactionId: TransactionId? = null, val senderId: UserId, val senderDisplayName: String?, val senderAvatar: AvatarData, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt index 97bbf925bd..0db13592e1 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt @@ -37,6 +37,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags 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.TransactionId import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.room.MatrixRoom @@ -510,7 +511,7 @@ class MessageComposerPresenterTest { fun anEditMode( eventId: EventId? = AN_EVENT_ID, message: String = A_MESSAGE, - transactionId: String? = null, + transactionId: TransactionId? = null, ) = MessageComposerMode.Edit(eventId, message, transactionId) fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, AN_EVENT_ID, A_MESSAGE) fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/TransactionId.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/TransactionId.kt new file mode 100644 index 0000000000..0de5ccd651 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/TransactionId.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.matrix.api.core + +import java.io.Serializable + +@JvmInline +value class TransactionId(val value: String) : Serializable { + override fun toString(): String = value +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt index 05dfda714c..6a42aa0a18 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt @@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.ProgressCallback import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.TransactionId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.FileInfo @@ -70,7 +71,7 @@ interface MatrixRoom : Closeable { suspend fun sendMessage(message: String): Result - suspend fun editMessage(originalEventId: EventId?, transactionId: String?, message: String): Result + suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, message: String): Result suspend fun replyMessage(eventId: EventId, message: String): Result @@ -88,9 +89,9 @@ interface MatrixRoom : Closeable { suspend fun forwardEvent(eventId: EventId, roomIds: List): Result - suspend fun retrySendMessage(transactionId: String): Result + suspend fun retrySendMessage(transactionId: TransactionId): Result - suspend fun cancelSend(transactionId: String): Result + suspend fun cancelSend(transactionId: TransactionId): Result suspend fun leave(): Result diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MatrixTimelineItem.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MatrixTimelineItem.kt index f84f1875e4..f7b515c26c 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MatrixTimelineItem.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MatrixTimelineItem.kt @@ -17,6 +17,7 @@ package io.element.android.libraries.matrix.api.timeline 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.timeline.item.event.EventTimelineItem import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem @@ -24,7 +25,7 @@ sealed interface MatrixTimelineItem { data class Event(val event: EventTimelineItem) : MatrixTimelineItem { val uniqueId: String = event.uniqueIdentifier val eventId: EventId? = event.eventId - val transactionId: String? = event.transactionId + val transactionId: TransactionId? = event.transactionId } data class Virtual(val virtual: VirtualTimelineItem) : MatrixTimelineItem diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt index e2a86fbb3c..c6aaaf4424 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt @@ -17,13 +17,14 @@ package io.element.android.libraries.matrix.api.timeline.item.event 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.core.UserId import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo data class EventTimelineItem( val uniqueIdentifier: String, val eventId: EventId?, - val transactionId: String?, + val transactionId: TransactionId?, val isEditable: Boolean, val isLocal: Boolean, val isOwn: Boolean, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt index 11d75641c5..9eced2d5cb 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt @@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.ProgressCallback import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.TransactionId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.FileInfo @@ -218,10 +219,10 @@ class RustMatrixRoom( } } - override suspend fun editMessage(originalEventId: EventId?, transactionId: String?, message: String): Result = withContext(roomDispatcher) { + override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, message: String): Result = withContext(roomDispatcher) { if (originalEventId != null) { runCatching { - innerRoom.edit(/* TODO use content */ message, originalEventId.value, transactionId) + innerRoom.edit(/* TODO use content */ message, originalEventId.value, transactionId?.value) } } else { runCatching { @@ -326,17 +327,17 @@ class RustMatrixRoom( } } - override suspend fun retrySendMessage(transactionId: String): Result = + override suspend fun retrySendMessage(transactionId: TransactionId): Result = withContext(roomDispatcher) { runCatching { - innerRoom.retrySend(transactionId) + innerRoom.retrySend(transactionId.value) } } - override suspend fun cancelSend(transactionId: String): Result = + override suspend fun cancelSend(transactionId: TransactionId): Result = withContext(roomDispatcher) { runCatching { - innerRoom.cancelSend(transactionId) + innerRoom.cancelSend(transactionId.value) } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt index d250072267..a6af6eb133 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt @@ -17,6 +17,7 @@ package io.element.android.libraries.matrix.impl.timeline.item.event 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.core.UserId import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction @@ -35,7 +36,7 @@ class EventTimelineItemMapper(private val contentMapper: TimelineEventContentMap EventTimelineItem( uniqueIdentifier = it.uniqueIdentifier(), eventId = it.eventId()?.let(::EventId), - transactionId = it.transactionId(), + transactionId = it.transactionId()?.let(::TransactionId), isEditable = it.isEditable(), isLocal = it.isLocal(), isOwn = it.isOwn(), diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt index 4cc9422eb7..e9b7341d33 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt @@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SpaceId import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.core.TransactionId import io.element.android.libraries.matrix.api.core.UserId const val A_USER_NAME = "alice" @@ -37,7 +38,7 @@ val A_ROOM_ID_2 = RoomId("!aRoomId2:domain") val A_THREAD_ID = ThreadId("\$aThreadId") val AN_EVENT_ID = EventId("\$anEventId") val AN_EVENT_ID_2 = EventId("\$anEventId2") -const val A_TRANSACTION_ID = "aTransactionId" +val A_TRANSACTION_ID = TransactionId("aTransactionId") const val A_UNIQUE_ID = "aUniqueId" const val A_ROOM_NAME = "A room name" diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt index 3f265f392a..176d9109da 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt @@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.ProgressCallback import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.TransactionId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.media.AudioInfo @@ -164,17 +165,17 @@ class FakeMatrixRoom( return toggleReactionResult } - override suspend fun retrySendMessage(transactionId: String): Result { + override suspend fun retrySendMessage(transactionId: TransactionId): Result { retrySendMessageCount++ return retrySendMessageResult } - override suspend fun cancelSend(transactionId: String): Result { + override suspend fun cancelSend(transactionId: TransactionId): Result { cancelSendCount++ return cancelSendResult } - override suspend fun editMessage(originalEventId: EventId?, transactionId: String?, message: String): Result { + override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, message: String): Result { editMessageCalls += message return Result.success(Unit) } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt index 7580a32e18..75187ed6d3 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt @@ -18,6 +18,7 @@ package io.element.android.libraries.matrix.test.room import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.TransactionId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.RoomSummary import io.element.android.libraries.matrix.api.room.RoomSummaryDetails @@ -89,7 +90,7 @@ fun aRoomMessage( fun anEventTimelineItem( uniqueIdentifier: String = A_UNIQUE_ID, eventId: EventId = AN_EVENT_ID, - transactionId: String? = null, + transactionId: TransactionId? = null, isEditable: Boolean = false, isLocal: Boolean = false, isOwn: Boolean = false, diff --git a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerMode.kt b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerMode.kt index f0ccc76f3c..06ad1575e9 100644 --- a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerMode.kt +++ b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerMode.kt @@ -18,6 +18,7 @@ package io.element.android.libraries.textcomposer import android.os.Parcelable import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.TransactionId import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo import kotlinx.parcelize.Parcelize @@ -29,7 +30,7 @@ sealed interface MessageComposerMode : Parcelable { MessageComposerMode @Parcelize - data class Edit(override val eventId: EventId?, override val defaultContent: CharSequence, val transactionId: String?) : + data class Edit(override val eventId: EventId?, override val defaultContent: CharSequence, val transactionId: TransactionId?) : Special(eventId, defaultContent) @Parcelize diff --git a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index b757f69f03..5c82ff3fdd 100644 --- a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -77,6 +77,7 @@ import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Text 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.ui.components.AttachmentThumbnail import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo @@ -449,7 +450,7 @@ fun TextComposerEditPreview() = ElementPreview { TextComposer( onSendMessage = {}, onComposerTextChange = {}, - composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", "1234"), + composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", TransactionId("1234")), onResetComposerMode = {}, composerCanSendMessage = true, composerText = "A message", From d7aef3818a08901cc3855b808c79cfead4b1fe9b Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 13 Jul 2023 13:05:56 +0200 Subject: [PATCH 04/42] Timeline: introduce origin on timeline items --- .../impl/timeline/TimelineStateProvider.kt | 1 + .../event/TimelineItemEventFactory.kt | 1 + .../impl/timeline/model/TimelineItem.kt | 2 ++ .../timeline/item/event/EventTimelineItem.kt | 1 + .../item/event/TimelineItemEventOrigin.kt | 21 +++++++++++++++++++ .../item/event/EventTimelineItemMapper.kt | 11 ++++++++++ 6 files changed, 37 insertions(+) create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/TimelineItemEventOrigin.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt index c92658f7e8..b3c6f01303 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt @@ -126,6 +126,7 @@ internal fun aTimelineItemEvent( localSendState = sendState, inReplyTo = inReplyTo, debugInfo = debugInfo, + origin = null ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt index 9a09c77a34..6bc5df1e79 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt @@ -85,6 +85,7 @@ class TimelineItemEventFactory @Inject constructor( localSendState = currentTimelineItem.event.localSendState, inReplyTo = currentTimelineItem.event.inReplyTo(), debugInfo = currentTimelineItem.event.debugInfo, + origin = currentTimelineItem.event.origin, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt index 21b7e8607f..7f95b30409 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt @@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState +import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin import kotlinx.collections.immutable.ImmutableList @Immutable @@ -65,6 +66,7 @@ sealed interface TimelineItem { val localSendState: LocalEventSendState?, val inReplyTo: InReplyTo?, val debugInfo: TimelineItemDebugInfo, + val origin: TimelineItemEventOrigin?, ) : TimelineItem { val showSenderInformation = groupPosition.isNew() && !isMine diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt index 854d38b7dd..67dd580426 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt @@ -34,6 +34,7 @@ data class EventTimelineItem( val timestamp: Long, val content: EventContent, val debugInfo: TimelineItemDebugInfo, + val origin: TimelineItemEventOrigin?, ) { fun inReplyTo(): InReplyTo? { return (content as? MessageContent)?.inReplyTo diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/TimelineItemEventOrigin.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/TimelineItemEventOrigin.kt new file mode 100644 index 0000000000..0f906e6719 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/TimelineItemEventOrigin.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.matrix.api.timeline.item.event + +enum class TimelineItemEventOrigin { + LOCAL, SYNC, PAGINATION; +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt index b4887faf68..ec6ca0897b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt @@ -19,11 +19,13 @@ package io.element.android.libraries.matrix.impl.timeline.item.event import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo +import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails import org.matrix.rustcomponents.sdk.Reaction +import org.matrix.rustcomponents.sdk.EventItemOrigin as RustEventItemOrigin import org.matrix.rustcomponents.sdk.EventSendState as RustEventSendState import org.matrix.rustcomponents.sdk.EventTimelineItem as RustEventTimelineItem import org.matrix.rustcomponents.sdk.EventTimelineItemDebugInfo as RustEventTimelineItemDebugInfo @@ -46,6 +48,7 @@ class EventTimelineItemMapper(private val contentMapper: TimelineEventContentMap timestamp = it.timestamp().toLong(), content = contentMapper.map(it.content()), debugInfo = it.debugInfo().map(), + origin = it.origin()?.map() ) } } @@ -90,3 +93,11 @@ private fun RustEventTimelineItemDebugInfo.map(): TimelineItemDebugInfo { latestEditedJson = latestEditJson, ) } + +private fun RustEventItemOrigin.map(): TimelineItemEventOrigin { + return when (this) { + RustEventItemOrigin.LOCAL -> TimelineItemEventOrigin.LOCAL + RustEventItemOrigin.SYNC -> TimelineItemEventOrigin.SYNC + RustEventItemOrigin.PAGINATION -> TimelineItemEventOrigin.PAGINATION + } +} From 8f2afbd79770cf29a1e4c4afa1b2b6028bfee6d9 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 13 Jul 2023 13:08:25 +0200 Subject: [PATCH 05/42] Timeline : improve auto-scroll --- .../impl/timeline/TimelinePresenter.kt | 61 ++++++++++++---- .../messages/impl/timeline/TimelineState.kt | 1 + .../impl/timeline/TimelineStateProvider.kt | 3 +- .../messages/impl/timeline/TimelineView.kt | 69 ++++++++++--------- 4 files changed, 87 insertions(+), 47 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 0833ff2205..353d94e390 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -22,21 +22,25 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MessageEventType +import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin import io.element.android.libraries.matrix.ui.room.canSendEventAsState import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import javax.inject.Inject private const val backPaginationEventLimit = 20 @@ -45,42 +49,57 @@ private const val backPaginationPageSize = 50 class TimelinePresenter @Inject constructor( private val timelineItemsFactory: TimelineItemsFactory, private val room: MatrixRoom, + private val dispatchers: CoroutineDispatchers, + private val appScope: CoroutineScope, ) : Presenter { private val timeline = room.timeline @Composable override fun present(): TimelineState { - val localCoroutineScope = rememberCoroutineScope() + val localScope = rememberCoroutineScope() val highlightedEventId: MutableState = rememberSaveable { mutableStateOf(null) } - var lastReadMarkerIndex by rememberSaveable { mutableStateOf(Int.MAX_VALUE) } - var lastReadMarkerId by rememberSaveable { mutableStateOf(null) } + var lastReadReceiptIndex by rememberSaveable { mutableStateOf(Int.MAX_VALUE) } + var lastReadReceiptId by rememberSaveable { mutableStateOf(null) } val timelineItems by timelineItemsFactory.collectItemsAsState() val paginationState by timeline.paginationState.collectAsState() - val syncUpdateFlow = room.syncUpdateFlow.collectAsState() val userHasPermissionToSendMessage by room.canSendEventAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value) + val prevMostRecentItemId = rememberSaveable { mutableStateOf(null) } + val hasNewItems = remember { mutableStateOf(false) } + + fun CoroutineScope.sendReadReceiptIfNeeded(firstVisibleIndex: Int) = launch(dispatchers.computation) { + // Get last valid EventId seen by the user, as the first index might refer to a Virtual item + val eventId = getLastEventIdBeforeOrAt(firstVisibleIndex, timelineItems) + if (eventId != null && firstVisibleIndex <= lastReadReceiptIndex && eventId != lastReadReceiptId) { + lastReadReceiptIndex = firstVisibleIndex + lastReadReceiptId = eventId + timeline.sendReadReceipt(eventId) + } + } + fun handleEvents(event: TimelineEvents) { when (event) { - TimelineEvents.LoadMore -> localCoroutineScope.paginateBackwards() + TimelineEvents.LoadMore -> localScope.paginateBackwards() is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId is TimelineEvents.OnScrollFinished -> { - // Get last valid EventId seen by the user, as the first index might refer to a Virtual item - val eventId = getLastEventIdBeforeOrAt(event.firstIndex, timelineItems) ?: return - if (event.firstIndex <= lastReadMarkerIndex && eventId != lastReadMarkerId) { - lastReadMarkerIndex = event.firstIndex - lastReadMarkerId = eventId - localCoroutineScope.sendReadReceipt(eventId) + if (event.firstIndex == 0) { + hasNewItems.value = false } + appScope.sendReadReceiptIfNeeded(event.firstIndex) } } } + LaunchedEffect(timelineItems.size) { + computeHasNewItems(timelineItems, prevMostRecentItemId, hasNewItems) + } + LaunchedEffect(Unit) { timeline .timelineItems @@ -98,10 +117,26 @@ class TimelinePresenter @Inject constructor( canReply = userHasPermissionToSendMessage, paginationState = paginationState, timelineItems = timelineItems, + hasNewItems = hasNewItems.value, eventSink = ::handleEvents ) } + private suspend fun computeHasNewItems( + timelineItems: ImmutableList, + prevMostRecentItemId: MutableState, + hasNewItemsState: MutableState + ) = withContext(dispatchers.computation) { + val newMostRecentItem = timelineItems.firstOrNull() + val prevMostRecentItemIdValue = prevMostRecentItemId.value + val newMostRecentItemId = newMostRecentItem?.identifier() + hasNewItemsState.value = prevMostRecentItemIdValue != null && + newMostRecentItem is TimelineItem.Event && + newMostRecentItem.origin != TimelineItemEventOrigin.PAGINATION && + newMostRecentItemId != prevMostRecentItemIdValue + prevMostRecentItemId.value = newMostRecentItemId + } + private fun getLastEventIdBeforeOrAt(index: Int, items: ImmutableList): EventId? { for (item in items.subList(index, items.count())) { if (item is TimelineItem.Event) { @@ -114,8 +149,4 @@ class TimelinePresenter @Inject constructor( private fun CoroutineScope.paginateBackwards() = launch { timeline.paginateBackwards(backPaginationEventLimit, backPaginationPageSize) } - - private fun CoroutineScope.sendReadReceipt(eventId: EventId) = launch { - timeline.sendReadReceipt(eventId) - } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt index 0aa1bd0160..ab5874d39c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt @@ -28,5 +28,6 @@ data class TimelineState( val highlightedEventId: EventId?, val canReply: Boolean, val paginationState: MatrixTimeline.PaginationState, + val hasNewItems: Boolean, val eventSink: (TimelineEvents) -> Unit ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt index b3c6f01303..4e62b8649f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt @@ -44,7 +44,8 @@ fun aTimelineState(timelineItems: ImmutableList = persistentListOf paginationState = MatrixTimeline.PaginationState(isBackPaginating = false, hasMoreToLoadBackwards = true), highlightedEventId = null, canReply = true, - eventSink = {} + hasNewItems = false, + eventSink = {}, ) internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index 1e00bea1a9..3bbaffab9f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -49,7 +49,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.shadow import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.tooling.preview.Preview @@ -72,7 +71,6 @@ import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.theme.ElementTheme -import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.launch @Composable @@ -141,8 +139,8 @@ fun TimelineView( TimelineScrollHelper( lazyListState = lazyListState, - timelineItems = state.timelineItems, - onScrollFinishedAt = ::onScrollFinishedAt, + hasNewItems = state.hasNewItems, + onScrollFinishedAt = ::onScrollFinishedAt ) } } @@ -238,53 +236,62 @@ fun TimelineItemRow( } @Composable -internal fun BoxScope.TimelineScrollHelper( +private fun BoxScope.TimelineScrollHelper( lazyListState: LazyListState, - timelineItems: ImmutableList, - onScrollFinishedAt: (Int) -> Unit = {}, + hasNewItems: Boolean, + onScrollFinishedAt: (Int) -> Unit, ) { val coroutineScope = rememberCoroutineScope() - val firstVisibleItemIndex by remember { derivedStateOf { lazyListState.firstVisibleItemIndex } } val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } } - val shouldAutoScrollToBottom by remember { derivedStateOf { lazyListState.firstVisibleItemIndex < 2 } } - val showScrollToBottomButton by remember { derivedStateOf { lazyListState.firstVisibleItemIndex > 0 } } + val canAutoScroll by remember { derivedStateOf { lazyListState.firstVisibleItemIndex < 3 } } - LaunchedEffect(timelineItems, firstVisibleItemIndex) { - if (!isScrollFinished) return@LaunchedEffect - - // Auto-scroll when new timeline items appear - if (shouldAutoScrollToBottom) { + LaunchedEffect(canAutoScroll, hasNewItems) { + val shouldAutoScroll = isScrollFinished && canAutoScroll && hasNewItems + if (shouldAutoScroll) { coroutineScope.launch { lazyListState.animateScrollToItem(0) } } } - LaunchedEffect(isScrollFinished) { - if (!isScrollFinished) return@LaunchedEffect - // Notify the parent composable about the first visible item index when scrolling finishes - onScrollFinishedAt(firstVisibleItemIndex) + LaunchedEffect(isScrollFinished) { + if (isScrollFinished) { + // Notify the parent composable about the first visible item index when scrolling finishes + onScrollFinishedAt(lazyListState.firstVisibleItemIndex) + } } - // Jump to bottom button (display also in previews) - AnimatedVisibility( + JumpToBottomButton( + isVisible = !canAutoScroll, modifier = Modifier .align(Alignment.BottomEnd) .padding(end = 24.dp, bottom = 12.dp), - visible = showScrollToBottomButton || LocalInspectionMode.current, + onClick = { + coroutineScope.launch { + if (lazyListState.firstVisibleItemIndex > 10) { + lazyListState.scrollToItem(0) + } else { + lazyListState.animateScrollToItem(0) + } + } + } + ) +} + +@Composable +private fun JumpToBottomButton( + isVisible: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + modifier = modifier, + visible = isVisible || LocalInspectionMode.current, enter = scaleIn(animationSpec = tween(100)), exit = scaleOut(animationSpec = tween(100)), ) { FloatingActionButton( - onClick = { - coroutineScope.launch { - if (firstVisibleItemIndex > 10) { - lazyListState.scrollToItem(0) - } else { - lazyListState.animateScrollToItem(0) - } - } - }, + onClick = onClick, elevation = FloatingActionButtonDefaults.elevation(4.dp, 4.dp, 4.dp, 4.dp), shape = CircleShape, modifier = Modifier.size(36.dp), From 231f278adb49a61f23d0e7daf8716b7822ecdcf5 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 13 Jul 2023 17:09:20 +0200 Subject: [PATCH 06/42] Timeline: fix some tests and a one more --- .../impl/timeline/TimelineStateProvider.kt | 5 +- .../messages/impl/timeline/TimelineView.kt | 1 + .../messages/MessagesPresenterTest.kt | 2 + .../messages/fixtures/aMessageEvent.kt | 1 + .../timeline/TimelinePresenterTest.kt | 123 +++++++++++------- .../groups/TimelineItemGrouperTest.kt | 1 + .../matrix/test/room/RoomSummaryFixture.kt | 24 +++- .../test/timeline/FakeMatrixTimeline.kt | 14 +- .../android/tests/testutils/LongTask.kt | 20 +++ 9 files changed, 137 insertions(+), 54 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt index 4e62b8649f..5b5319f952 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt @@ -30,8 +30,8 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo -import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -154,13 +154,14 @@ internal fun aTimelineItemDebugInfo( model, originalJson, latestEditedJson ) -fun aGroupedEvents(): TimelineItem.GroupedEvents { +fun aGroupedEvents(id: Long = 0): TimelineItem.GroupedEvents { val event = aTimelineItemEvent( isMine = true, content = aTimelineItemStateEventContent(), groupPosition = TimelineItemGroupPosition.None ) return TimelineItem.GroupedEvents( + id = id.toString(), events = listOf( event, event, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index 3bbaffab9f..0404edf7db 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -262,6 +262,7 @@ private fun BoxScope.TimelineScrollHelper( } JumpToBottomButton( + // Use inverse of canAutoScroll otherwise we might briefly see the before the scroll animation is triggered isVisible = !canAutoScroll, modifier = Modifier .align(Alignment.BottomEnd) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt index 53034131dc..abbbeccf37 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt @@ -572,6 +572,8 @@ class MessagesPresenterTest { val timelinePresenter = TimelinePresenter( timelineItemsFactory = aTimelineItemsFactory(), room = matrixRoom, + dispatchers = coroutineDispatchers, + appScope = this ) val buildMeta = aBuildMeta() val actionListPresenter = ActionListPresenter(buildMeta = buildMeta) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/aMessageEvent.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/aMessageEvent.kt index 088df6060f..4f1edcb64f 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/aMessageEvent.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/aMessageEvent.kt @@ -52,4 +52,5 @@ internal fun aMessageEvent( localSendState = sendState, inReplyTo = inReplyTo, debugInfo = debugInfo, + origin = null ) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt index 2835155b14..ad6e41e483 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt @@ -23,22 +23,25 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.messages.fixtures.aTimelineItemsFactory import io.element.android.features.messages.impl.timeline.TimelineEvents import io.element.android.features.messages.impl.timeline.TimelinePresenter +import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory +import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.matrix.test.room.aMessageContent import io.element.android.libraries.matrix.test.room.anEventTimelineItem import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline +import io.element.android.tests.testutils.awaitWithLatch +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Test class TimelinePresenterTest { @Test fun `present - initial state`() = runTest { - val presenter = TimelinePresenter( - timelineItemsFactory = aTimelineItemsFactory(), - room = FakeMatrixRoom(), - ) + val presenter = createTimelinePresenter() moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -51,10 +54,7 @@ class TimelinePresenterTest { @Test fun `present - load more`() = runTest { - val presenter = TimelinePresenter( - timelineItemsFactory = aTimelineItemsFactory(), - room = FakeMatrixRoom(), - ) + val presenter = createTimelinePresenter() moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -73,10 +73,7 @@ class TimelinePresenterTest { @Test fun `present - set highlighted event`() = runTest { - val presenter = TimelinePresenter( - timelineItemsFactory = aTimelineItemsFactory(), - room = FakeMatrixRoom(), - ) + val presenter = createTimelinePresenter() moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -94,70 +91,106 @@ class TimelinePresenterTest { @Test fun `present - on scroll finished send read receipt if an event is before the index`() = runTest { - val timeline = FakeMatrixTimeline() - val timelineItemsFactory = aTimelineItemsFactory().apply { - replaceWith(listOf(MatrixTimelineItem.Event(0, anEventTimelineItem()))) - } - val room = FakeMatrixRoom(matrixTimeline = timeline) - val presenter = TimelinePresenter( - timelineItemsFactory = timelineItemsFactory, - room = room, + val timeline = FakeMatrixTimeline( + initialTimelineItems = listOf( + MatrixTimelineItem.Event(0, anEventTimelineItem()) + ) ) + val presenter = createTimelinePresenter(timeline) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { assertThat(timeline.sendReadReceiptCount).isEqualTo(0) val initialState = awaitItem() - - initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0)) - + awaitWithLatch { latch -> + timeline.sendReadReceiptLatch = latch + initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0)) + } assertThat(timeline.sendReadReceiptCount).isEqualTo(1) + cancelAndIgnoreRemainingEvents() } } @Test fun `present - on scroll finished will not send read receipt no event is before the index`() = runTest { - val timeline = FakeMatrixTimeline() - val timelineItemsFactory = aTimelineItemsFactory().apply { - replaceWith(listOf(MatrixTimelineItem.Event(0, anEventTimelineItem()))) - } - val room = FakeMatrixRoom(matrixTimeline = timeline) - val presenter = TimelinePresenter( - timelineItemsFactory = timelineItemsFactory, - room = room, + val timeline = FakeMatrixTimeline( + initialTimelineItems = listOf( + MatrixTimelineItem.Event(0, anEventTimelineItem()) + ) ) + val presenter = createTimelinePresenter(timeline) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { assertThat(timeline.sendReadReceiptCount).isEqualTo(0) val initialState = awaitItem() - - initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1)) - + awaitWithLatch { latch -> + timeline.sendReadReceiptLatch = latch + initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1)) + } assertThat(timeline.sendReadReceiptCount).isEqualTo(0) + cancelAndIgnoreRemainingEvents() } } @Test fun `present - on scroll finished will not send read receipt only virtual events exist before the index`() = runTest { - val timeline = FakeMatrixTimeline() - val timelineItemsFactory = aTimelineItemsFactory().apply { - replaceWith(listOf(MatrixTimelineItem.Virtual(0, VirtualTimelineItem.ReadMarker))) - } - val room = FakeMatrixRoom(matrixTimeline = timeline) - val presenter = TimelinePresenter( - timelineItemsFactory = timelineItemsFactory, - room = room, + val timeline = FakeMatrixTimeline( + initialTimelineItems = listOf( + MatrixTimelineItem.Virtual(0, VirtualTimelineItem.ReadMarker) + ) ) + val presenter = createTimelinePresenter(timeline) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { assertThat(timeline.sendReadReceiptCount).isEqualTo(0) val initialState = awaitItem() - - initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1)) - + awaitWithLatch { latch -> + timeline.sendReadReceiptLatch = latch + initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0)) + } assertThat(timeline.sendReadReceiptCount).isEqualTo(0) + cancelAndIgnoreRemainingEvents() } } + + @Test + fun `present - covers hasNewItems scenarios`() = runTest { + val timeline = FakeMatrixTimeline() + val presenter = createTimelinePresenter(timeline) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.hasNewItems).isFalse() + assertThat(initialState.timelineItems.size).isEqualTo(0) + timeline.updateTimelineItems { + listOf(MatrixTimelineItem.Event(0, anEventTimelineItem(content = aMessageContent()))) + } + skipItems(1) + assertThat(awaitItem().timelineItems.size).isEqualTo(1) + timeline.updateTimelineItems { items -> + items + listOf(MatrixTimelineItem.Event(1, anEventTimelineItem(content = aMessageContent()))) + } + skipItems(1) + assertThat(awaitItem().timelineItems.size).isEqualTo(2) + assertThat(awaitItem().hasNewItems).isTrue() + initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0)) + assertThat(awaitItem().hasNewItems).isFalse() + cancelAndIgnoreRemainingEvents() + } + } + + private fun TestScope.createTimelinePresenter( + timeline: MatrixTimeline = FakeMatrixTimeline(), + timelineItemsFactory: TimelineItemsFactory = aTimelineItemsFactory() + ): TimelinePresenter { + return TimelinePresenter( + timelineItemsFactory = timelineItemsFactory, + room = FakeMatrixRoom(matrixTimeline = timeline), + dispatchers = testCoroutineDispatchers(), + appScope = this + ) + } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/groups/TimelineItemGrouperTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/groups/TimelineItemGrouperTest.kt index 3cab1fe44c..71836e9f5e 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/groups/TimelineItemGrouperTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/groups/TimelineItemGrouperTest.kt @@ -45,6 +45,7 @@ class TimelineItemGrouperTest { localSendState = LocalEventSendState.Sent(AN_EVENT_ID), inReplyTo = null, debugInfo = aTimelineItemDebugInfo(), + origin = null ) private val aNonGroupableItem = aMessageEvent() private val aNonGroupableItemNoEvent = TimelineItem.Virtual("virtual", aTimelineItemDaySeparatorModel("Today")) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt index 0a956577ca..d9cd6dc867 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt @@ -25,14 +25,17 @@ import io.element.android.libraries.matrix.api.room.message.RoomMessage import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.event.EventContent import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction -import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.MessageType import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails +import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType 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_NAME -import io.element.android.libraries.matrix.test.A_UNIQUE_ID import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_NAME @@ -114,6 +117,7 @@ fun anEventTimelineItem( timestamp = timestamp, content = content, debugInfo = debugInfo, + origin = null, ) fun aProfileTimelineDetails( @@ -138,6 +142,21 @@ fun aProfileChangeMessageContent( prevAvatarUrl = prevAvatarUrl, ) +fun aMessageContent( + body: String = "body", + inReplyTo: InReplyTo? = null, + isEdited: Boolean = false, + messageType: MessageType = TextMessageType( + body = body, + formatted = null + ) +) = MessageContent( + body = body, + inReplyTo = inReplyTo, + isEdited = isEdited, + type = messageType +) + fun aTimelineItemDebugInfo( model: String = "Rust(Model())", originalJson: String? = null, @@ -145,3 +164,4 @@ fun aTimelineItemDebugInfo( ) = TimelineItemDebugInfo( model, originalJson, latestEditedJson ) + diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeMatrixTimeline.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeMatrixTimeline.kt index b49a5490ce..73bc5fb597 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeMatrixTimeline.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeMatrixTimeline.kt @@ -19,6 +19,8 @@ package io.element.android.libraries.matrix.test.timeline import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.tests.testutils.simulateLongTask +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -36,6 +38,8 @@ class FakeMatrixTimeline( var sendReadReceiptCount = 0 private set + var sendReadReceiptLatch: CompletableDeferred? = null + fun updatePaginationState(update: (MatrixTimeline.PaginationState.() -> MatrixTimeline.PaginationState)) { _paginationState.getAndUpdate(update) } @@ -62,13 +66,13 @@ class FakeMatrixTimeline( return Result.success(Unit) } - - override suspend fun fetchDetailsForEvent(eventId: EventId): Result { - return Result.success(Unit) + override suspend fun fetchDetailsForEvent(eventId: EventId): Result = simulateLongTask { + Result.success(Unit) } - override suspend fun sendReadReceipt(eventId: EventId): Result { + override suspend fun sendReadReceipt(eventId: EventId): Result = simulateLongTask { sendReadReceiptCount++ - return Result.success(Unit) + sendReadReceiptLatch?.complete(Unit) + Result.success(Unit) } } diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/LongTask.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/LongTask.kt index 154877efe9..8a5158dbf8 100644 --- a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/LongTask.kt +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/LongTask.kt @@ -16,7 +16,12 @@ package io.element.android.tests.testutils +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds /** * Workaround for https://github.com/cashapp/molecule/issues/249. @@ -26,3 +31,18 @@ suspend inline fun simulateLongTask(lambda: () -> T): T { delay(1) return lambda() } + +/** + * Can be used for testing events in Presenter, where the event does not emit new state. + * If the (virtual) timeout is passed, we release the latch manually. + */ +suspend fun awaitWithLatch(timeout: Duration = 300.milliseconds, block: (CompletableDeferred) -> Unit) { + val latch = CompletableDeferred() + try { + withTimeout(timeout) { + latch.also(block).await() + } + } catch (exception: TimeoutCancellationException) { + latch.complete(Unit) + } +} From a07305314f4258feec3ee1eb2c889257679ee8b9 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 13 Jul 2023 17:21:56 +0200 Subject: [PATCH 07/42] Timeline: make group id really stable --- .../timeline/groups/TimelineItemGrouper.kt | 39 ++++++++++++++++--- .../impl/timeline/model/TimelineItem.kt | 7 ++-- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouper.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouper.kt index b04509ba22..bee2b055f3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouper.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouper.kt @@ -17,10 +17,20 @@ package io.element.android.features.messages.impl.timeline.groups import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.di.SingleIn import kotlinx.collections.immutable.toImmutableList import javax.inject.Inject +@SingleIn(RoomScope::class) class TimelineItemGrouper @Inject constructor() { + + /** + * Keys are identifier of items in a group, only one by group will be kept. + * Values are the actual groupIds. + */ + private val groupIds = HashMap() + /** * Create a new list of [TimelineItem] by grouping some of them into [TimelineItem.GroupedEvents]. */ @@ -34,14 +44,14 @@ class TimelineItemGrouper @Inject constructor() { // timelineItem cannot be grouped if (currentGroup.isNotEmpty()) { // There is a pending group, create a TimelineItem.GroupedEvents if there is more than 1 Event in the pending group. - result.addGroup(currentGroup) + result.addGroup(groupIds, currentGroup) currentGroup.clear() } result.add(timelineItem) } } if (currentGroup.isNotEmpty()) { - result.addGroup(currentGroup) + result.addGroup(groupIds, currentGroup) } return result } @@ -51,16 +61,33 @@ class TimelineItemGrouper @Inject constructor() { * Will add a group if there is more than 1 item, else add the item to the list. */ private fun MutableList.addGroup( - group: MutableList + groupIds: MutableMap, + groupOfItems: MutableList ) { - if (group.size == 1) { + if (groupOfItems.size == 1) { // Do not create a group with just 1 item, just add the item to the result - add(group.first()) + add(groupOfItems.first()) } else { + val groupId = groupIds.getOrPutGroupId(groupOfItems) add( TimelineItem.GroupedEvents( - events = group.toImmutableList() + id = groupId, + events = groupOfItems.toImmutableList() ) ) } } + +private fun MutableMap.getOrPutGroupId(timelineItems: List): String { + assert(timelineItems.isNotEmpty()) + for (item in timelineItems) { + val itemIdentifier = item.identifier() + if (this.contains(itemIdentifier)) { + return this[itemIdentifier]!! + } + } + val itemIdentifier = timelineItems.first().identifier() + return "${itemIdentifier}_group".also { groupId -> + this[itemIdentifier] = groupId + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt index 7f95b30409..b415d128a2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt @@ -82,9 +82,8 @@ sealed interface TimelineItem { @Immutable data class GroupedEvents( + val id: String, val events: ImmutableList, - ) : TimelineItem { - // use last id with a suffix. Last will not change in cas of new event from backpagination. - val id = "${events.last().id}_group" - } + ) : TimelineItem + } From 866a7bd8efd48db080d0818ff0d3b2938d776d7c Mon Sep 17 00:00:00 2001 From: ElementBot Date: Thu, 13 Jul 2023 15:38:07 +0000 Subject: [PATCH 08/42] Update screenshots --- ...Group_TimelineViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png | 4 ++-- ...Group_TimelineViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png | 4 ++-- ...roup_TimelineViewDarkPreview_0_null_10,NEXUS_5,1.0,en].png | 4 ++-- ...roup_TimelineViewDarkPreview_0_null_11,NEXUS_5,1.0,en].png | 4 ++-- ...roup_TimelineViewDarkPreview_0_null_12,NEXUS_5,1.0,en].png | 4 ++-- ...Group_TimelineViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png | 4 ++-- ...Group_TimelineViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png | 4 ++-- ...Group_TimelineViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png | 4 ++-- ...Group_TimelineViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png | 4 ++-- ...Group_TimelineViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png | 4 ++-- ...Group_TimelineViewDarkPreview_0_null_7,NEXUS_5,1.0,en].png | 4 ++-- ...Group_TimelineViewDarkPreview_0_null_8,NEXUS_5,1.0,en].png | 4 ++-- ...Group_TimelineViewDarkPreview_0_null_9,NEXUS_5,1.0,en].png | 4 ++-- ...roup_TimelineViewLightPreview_0_null_0,NEXUS_5,1.0,en].png | 4 ++-- ...roup_TimelineViewLightPreview_0_null_1,NEXUS_5,1.0,en].png | 4 ++-- ...oup_TimelineViewLightPreview_0_null_10,NEXUS_5,1.0,en].png | 4 ++-- ...oup_TimelineViewLightPreview_0_null_11,NEXUS_5,1.0,en].png | 4 ++-- ...oup_TimelineViewLightPreview_0_null_12,NEXUS_5,1.0,en].png | 4 ++-- ...roup_TimelineViewLightPreview_0_null_2,NEXUS_5,1.0,en].png | 4 ++-- ...roup_TimelineViewLightPreview_0_null_3,NEXUS_5,1.0,en].png | 4 ++-- ...roup_TimelineViewLightPreview_0_null_4,NEXUS_5,1.0,en].png | 4 ++-- ...roup_TimelineViewLightPreview_0_null_5,NEXUS_5,1.0,en].png | 4 ++-- ...roup_TimelineViewLightPreview_0_null_6,NEXUS_5,1.0,en].png | 4 ++-- ...roup_TimelineViewLightPreview_0_null_7,NEXUS_5,1.0,en].png | 4 ++-- ...roup_TimelineViewLightPreview_0_null_8,NEXUS_5,1.0,en].png | 4 ++-- ...roup_TimelineViewLightPreview_0_null_9,NEXUS_5,1.0,en].png | 4 ++-- ...Group_MessagesViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png | 4 ++-- ...Group_MessagesViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png | 4 ++-- ...Group_MessagesViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png | 4 ++-- ...Group_MessagesViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png | 4 ++-- ...Group_MessagesViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png | 4 ++-- ...roup_MessagesViewLightPreview_0_null_0,NEXUS_5,1.0,en].png | 4 ++-- ...roup_MessagesViewLightPreview_0_null_1,NEXUS_5,1.0,en].png | 4 ++-- ...roup_MessagesViewLightPreview_0_null_2,NEXUS_5,1.0,en].png | 4 ++-- ...roup_MessagesViewLightPreview_0_null_3,NEXUS_5,1.0,en].png | 4 ++-- ...roup_MessagesViewLightPreview_0_null_4,NEXUS_5,1.0,en].png | 4 ++-- 36 files changed, 72 insertions(+), 72 deletions(-) diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png index 1d34a00941..9f5b7d48c6 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:651319df939573207244eb84d9cea2bfac1c216e704f58c27533d3b2d98c4c64 -size 51933 +oid sha256:1a3b5bbcdd1593e81384b335045bdcb0b3e01782868993e9f6437a15ca39dbca +size 51380 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png index 4fab21b0af..ba9f8a8d63 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b0e751aee2e0f3fa735bbbb6b96d561fa485ebb3244dd51e3842c1f685cb1772 -size 63335 +oid sha256:7e26db0a0a8d767d6e732c1d2742cba3c3475d761b0c3017ff01cee7e3d362a5 +size 62773 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_10,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_10,NEXUS_5,1.0,en].png index dce1f75bc4..7d12d81b18 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_10,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_10,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3182e3cd2971af26007edfbee2d0ad058eb1bae0fb79d110800f0980c982035d -size 50045 +oid sha256:3d5cae7d73178aecc12fbf5f1f27c9e66608b90d462deec862a881b403469e93 +size 49475 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_11,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_11,NEXUS_5,1.0,en].png index 3fb99d2f69..44fd92b664 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_11,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_11,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e836190c744d6a65232c870544d4f1ec1020074c752585966488cd56e7bf6709 -size 66264 +oid sha256:fe9456f4446104142221e28167a578a2b5cac772dc35aeb14352c0d422dd6fb0 +size 65726 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_12,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_12,NEXUS_5,1.0,en].png index 00ccd6eb51..ee3a9ae3fe 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_12,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_12,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ba5b9161bd13d3185c4233b1c991c377f86ab7d33e61d549f1be0c89879db8f3 -size 56672 +oid sha256:cad3b5ce023d890fd4a5ff0f5bbabb678bc6d5c76d3476e8053673c06f360c8b +size 56102 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png index abdb75b960..8e578cb1f9 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9417f2631c429f047e799eba52183dc4a8be202f0110280332f837dd7b60c3d2 -size 229653 +oid sha256:71b3c712fb8e4bca178afed2de6f4184bb717e3415622e97f14bb4fe36aaf9d5 +size 229097 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png index 16994e6b8a..13d92ee329 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:51bfd93f2bd0bf313e675d63a89fddacfb007fbe207ca15871d3f94df14c9d24 -size 230633 +oid sha256:f44d772e5b9fe65a79e05aaaa82536b19382f9d821c0412c47a7d330d539ffd2 +size 230080 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png index 09e1fb4c35..4a58253b47 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:085091b7cb44ada72a16af6e3c652dd85589455648949db3bf37ef4160a8ebe9 -size 71401 +oid sha256:41a6c2dd81698696708802276b615a78ff22cc7cb8ae2d4b8d8cc862bfe44a24 +size 70856 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png index 2066060843..92bbf1dd70 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1eed02746fd9373275f6d66a283f3c9b0719e19d44194a1ba0cbb701c7c5d729 -size 85494 +oid sha256:f729f89dce978bb9e061826389e0db1af0e18835e4cd1ab6cbfaa31f4fd3152f +size 84942 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png index f01d3832e6..ace73de65e 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eddf42039c5629333db7d7b56abbd7dba9af8c70bb4787e4101a768e264c3199 -size 174401 +oid sha256:03a4b62920af61098e850cfca6a6f43acd7b310094fb70e1c3e4b7e29465e244 +size 173851 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_7,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_7,NEXUS_5,1.0,en].png index 94b7b6f75c..afb2440466 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_7,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_7,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:147052b5017396f02c0381ba8084b6c22d893e6ccc9e3551ce34ade7c05c61bc -size 165153 +oid sha256:ebcbb40acf19fb489f6a8068a22af0b79c304be46863e77fa12b3b333065a1b2 +size 164622 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_8,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_8,NEXUS_5,1.0,en].png index e32ec387f4..08c13f8f2a 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_8,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_8,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7655aa9de18743923bbe410b7ea05fe2d00ce716dc43f9673c8007951ec924ca -size 53316 +oid sha256:789ee5ccad8356198cdc0634b4e9a65ed44be2d26e7ce83a8662598c1bd8d4c2 +size 52765 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_9,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_9,NEXUS_5,1.0,en].png index cc48e8a3fd..300bcf7167 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_9,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_9,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8f81af9d9badaa6141390f83675ebeac7b3e9ec7412bb7ce2506e7123a5ec958 -size 65345 +oid sha256:7b40a2e5d60a906d7c35c3ece1854671f5b319b00ad077322d094cbf906c07f7 +size 64803 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_0,NEXUS_5,1.0,en].png index 518e317011..7c99f5e856 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9546f65d0347f7c7c07f07e730ec8a344549a43e47a21585b7d9375ac0a1c838 -size 53692 +oid sha256:54c12970e3563de958f88e4c538dd368f9810266060627393256a91741f7c6cf +size 53340 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_1,NEXUS_5,1.0,en].png index bc40cfe66b..def9cbe0d1 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a8a62fb60461f2f699ea010663f617ad12c949365b3f9125804cc9c674bb1f03 -size 65959 +oid sha256:3b7ae3084cb9d1ecee2e4db49c228516bdd7352683e797edf737c8d216922dec +size 65601 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_10,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_10,NEXUS_5,1.0,en].png index b9a3dd7e2c..258ce3f3de 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_10,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_10,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c34993f6832f141eeee8bbae9068828d58ebc900a21a658d5b0627096fe563c4 -size 51582 +oid sha256:dd034d439c08793e0dfd59f6bd5dcd88c06ded6cbe98172bf3cf296888e6d575 +size 51244 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_11,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_11,NEXUS_5,1.0,en].png index 9f4542c3f8..b2f16a9d23 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_11,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_11,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0d11d593be6d5ade5417aacef2aff79bc8940c971451669f01de4efa74fd7673 -size 69100 +oid sha256:d9d835cb1a420117b4d967181e2ca0fad71ba243d6a17bea08b82cae41f6b8e2 +size 68760 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_12,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_12,NEXUS_5,1.0,en].png index c2c6fb7af6..3bbd94a1c8 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_12,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_12,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3abc3d49852c70fe1d1c50e87cf86e91eb3cd91ff1cecca5ef0f1a30ab8dec9a -size 58883 +oid sha256:4ad09278ae2ebb8171adba96d9f6e91d0cc4f120b0b2368087796dadb37eeb87 +size 58539 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_2,NEXUS_5,1.0,en].png index 5149d82b20..1c32b14777 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ac58823ba2ae3c457f4199e71167693c6ea42839d912eb9b8a8d079aa26ec303 -size 230198 +oid sha256:145856c3a7ff43702403ee5b86a7119b0475f03fdbc0f2e7f84e10350a64b150 +size 229842 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_3,NEXUS_5,1.0,en].png index 19a4f805d5..53f57d95d2 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_3,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5af0e9fa17bdf8562c0f4e2f31c0ff0e36e243ec3ae51f23eaca6fe67da9f6a6 -size 231168 +oid sha256:c1f0939b0c22ab89466953889e5bb63e11c45f02af79eeba3f377409af61d356 +size 230809 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_4,NEXUS_5,1.0,en].png index fa988a7752..4c991e3c30 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_4,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_4,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a84fdf49767a07d801a745ffaf659f15ead717f8edd6aeea31acf0cf82f1e1e5 -size 73991 +oid sha256:082206122d4e6d9e6171b3b2444c576ff7bd47fb3946d8d98e2812654f39cd40 +size 73641 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_5,NEXUS_5,1.0,en].png index bbb417ede8..96270c69ae 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_5,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_5,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f494dc4f4b95e50fec21ee5c33a467711ce96dae8d2e68c091efc7874d682d8d -size 90101 +oid sha256:14ecdfd226a25743e3fac8850318216d6ec193755fa7559fd5523236b7835bc6 +size 89754 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_6,NEXUS_5,1.0,en].png index 6c41c7f016..b62d934c0c 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_6,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_6,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7359910c6bf938e2bb226b1ef7072de3441bd514f1c62286dfa5a1a9b2797d33 -size 364102 +oid sha256:d08de923f29ec6c3c2bf735ca0d015ca1097c95484119d1d39bb8c3f93f31100 +size 363776 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_7,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_7,NEXUS_5,1.0,en].png index 92bddc4e40..3e79fb521a 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_7,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_7,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:59a85793b104485cf64dc090ce295d21248502d98696c1d41c3906936a7990a6 -size 323105 +oid sha256:3e8832da336aea6c7ebb3621668569ff16238042fb9650afcd938594a59fd3ae +size 322771 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_8,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_8,NEXUS_5,1.0,en].png index 06e1aab216..ef081b76b8 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_8,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_8,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1841a5b097309e8809b4a0ce8e798a19794e77bdb29d0037a98ff507fd8ba64a -size 55252 +oid sha256:9a243d53d10ca2eaa249b22af8a9fddf1a8ccf60db4f3ad9374e31cf494fe878 +size 54909 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_9,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_9,NEXUS_5,1.0,en].png index 1028848722..0b13a93b0c 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_9,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_9,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:373ab2d1c52b7fd818be21a3b8ef167b7ec27a0bb9147d040c680316f30ac346 -size 67816 +oid sha256:3fdd478be89b47fcaf9725ca14c1e775087f1ccf2a266f3476a537bbc2b29922 +size 67476 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png index a20978d372..101f913aa8 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a6659d49217bb75dd62310ab1e5012466402203f2b9d11597da037f21e1273df -size 52885 +oid sha256:7c724bc77185a9ceec2cf092cb1d7865b13718d5320bd7ac4850bb85590f05b2 +size 52294 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png index 4b7eb025f1..cb28314caa 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c9bc30e1ff19b87b1ee95e78f9b5c000cda8760a6e84c6364b02e8b725092586 -size 54301 +oid sha256:a303894134ed06348b609e41cf109dcafcd994c3dffabc6d9ab436fe92605245 +size 53710 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png index 4ec03a627d..2ba437739f 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:20689b49bd2ef39ea8a9b2d492f1e6ebb7841bacca74a80fdbde33964407e635 -size 53116 +oid sha256:7523ec0d6defd7074af0c804fdde64fb8d421f6cfa729bc1c4f9858bd87c42d0 +size 52554 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png index afc72af080..73715a33a2 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7c3dd1a28aa9ef018e58435efe308822bfb49a99dc19366328edc6131039614f -size 55948 +oid sha256:1f48089147f7e089abfa64254143a80857ccc9f840aa60403e1aabc67e2b6d51 +size 55458 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png index c682612eee..9ce6f92309 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:565e8b74a4e88fdd1533c982a7210a0d79a01efb2d1059bc03b40b05555a23bb -size 51661 +oid sha256:7ff682ee8363d450bb76db72ea06deea87fa47692ce319b7dff315d2a10dfb6a +size 51033 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_0,NEXUS_5,1.0,en].png index 0de93cadc1..57eeca6786 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0d449f35be765f1c5f38ff7bc026b34ebeeb46dc2e17ddd11d153b916ac8aa3c -size 54626 +oid sha256:e84edf8adf1a89153dd45272d36e04561d66f2ea765ad9edecfd8d750ba99f97 +size 54237 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_1,NEXUS_5,1.0,en].png index 2f3be581f2..8baf81c8b1 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a7c8f63609634975edd98298264de50da56cd0a805a71e0a10cce846fcb077de -size 56077 +oid sha256:0bc9521bd1576d47ca6f643adf43ce3638d40328207d908ec863c72503d34f24 +size 55682 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_2,NEXUS_5,1.0,en].png index 7ccf9a8669..40900d7e92 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2e9579c9292407a7e837871a98785e135559c6584cca54b6ab997974f0ce24ae -size 54986 +oid sha256:c2209c3cc4e7de32ed92b3b0da4616b54d19091d43d21c0e4013728450d0d3f7 +size 54595 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_3,NEXUS_5,1.0,en].png index a3de6acb2e..5777c7f77e 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_3,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cf176efd967d4e00bae6589cc39cd24849f64f32d4d90e1d6461e29aa9f480f5 -size 57961 +oid sha256:d7bdd0ca39534b31c9d421e28337712b1cf2aaf841a766fcc6bdaf996c756bfa +size 57524 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_4,NEXUS_5,1.0,en].png index 9908572779..2e2bfa89cb 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_4,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_4,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:92a4c16a14f645b3db415bad8a69db1d8c3097f30087be45702f6a9c45ee6cd6 -size 53055 +oid sha256:ba349f81d5c417c612cae1263504ea5b4e83dc606ec20c3942368e8992b87ad4 +size 52886 From d47d0deb1752cbf011f0012f53345bb491e28d85 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 13 Jul 2023 21:29:01 +0200 Subject: [PATCH 09/42] Timeline: fix tests --- .../timeline/groups/TimelineItemGrouper.kt | 10 ++++-- .../groups/TimelineItemGrouperTest.kt | 31 +++++++++++++++---- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouper.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouper.kt index bee2b055f3..e9e9af6445 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouper.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouper.kt @@ -16,6 +16,7 @@ package io.element.android.features.messages.impl.timeline.groups +import androidx.annotation.VisibleForTesting import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.SingleIn @@ -86,8 +87,11 @@ private fun MutableMap.getOrPutGroupId(timelineItems: List - this[itemIdentifier] = groupId + val timelineItem = timelineItems.first() + return computeGroupIdWith(timelineItem).also { groupId -> + this[timelineItem.identifier()] = groupId } } + +@VisibleForTesting +internal fun computeGroupIdWith(timelineItem: TimelineItem): String = "${timelineItem.identifier()}_group" diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/groups/TimelineItemGrouperTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/groups/TimelineItemGrouperTest.kt index 71836e9f5e..d5ce31f87a 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/groups/TimelineItemGrouperTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/groups/TimelineItemGrouperTest.kt @@ -20,13 +20,13 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.messages.fixtures.aMessageEvent import io.element.android.features.messages.impl.timeline.aTimelineItemReactions import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper +import io.element.android.features.messages.impl.timeline.groups.computeGroupIdWith import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel import io.element.android.libraries.designsystem.components.avatar.anAvatarData import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.test.AN_EVENT_ID -import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.room.aTimelineItemDebugInfo import kotlinx.collections.immutable.toImmutableList @@ -36,7 +36,7 @@ class TimelineItemGrouperTest { private val sut = TimelineItemGrouper() private val aGroupableItem = TimelineItem.Event( - id = AN_EVENT_ID.value, + id = "0", senderId = A_USER_ID, senderAvatar = anAvatarData(), senderDisplayName = "", @@ -76,16 +76,17 @@ class TimelineItemGrouperTest { fun `test groupables and ensure reordering`() { val result = sut.group( listOf( - aGroupableItem.copy(id = AN_EVENT_ID_2.value), - aGroupableItem, + aGroupableItem.copy(id = "1"), + aGroupableItem.copy(id = "0"), ), ) assertThat(result).isEqualTo( listOf( TimelineItem.GroupedEvents( + computeGroupIdWith(aGroupableItem), events = listOf( - aGroupableItem, - aGroupableItem.copy(id = AN_EVENT_ID_2.value), + aGroupableItem.copy("0"), + aGroupableItem.copy(id = "1"), ).toImmutableList() ), ) @@ -128,6 +129,7 @@ class TimelineItemGrouperTest { assertThat(result).isEqualTo( listOf( TimelineItem.GroupedEvents( + computeGroupIdWith(aGroupableItem), events = listOf( aGroupableItem, aGroupableItem, @@ -135,6 +137,7 @@ class TimelineItemGrouperTest { ), aNonGroupableItem, TimelineItem.GroupedEvents( + computeGroupIdWith(aGroupableItem), events = listOf( aGroupableItem, aGroupableItem, @@ -144,4 +147,20 @@ class TimelineItemGrouperTest { ) ) } + + @Test + fun `when calling multiple time the method group over a growing list of groupable items, then groupId is stable`() { + // When + val groupableItems = mutableListOf( + aGroupableItem.copy(id = "1"), + aGroupableItem.copy(id = "2") + ) + val expectedGroupId = sut.group(groupableItems).first().identifier() + groupableItems.add(0, aGroupableItem.copy("3")) + groupableItems.add(2, aGroupableItem.copy("4")) + groupableItems.add(aGroupableItem.copy("5")) + val actualGroupId = sut.group(groupableItems).first().identifier() + // Then + assertThat(actualGroupId).isEqualTo(expectedGroupId) + } } From 4696e1dd4e1f75a34fff79011467b8354a5eecc2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 13 Jul 2023 22:07:07 +0000 Subject: [PATCH 10/42] Update dependency com.google.firebase:firebase-bom to v32.2.0 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ccfb5efdf7..5ff8dd4239 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -65,7 +65,7 @@ android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref android_desugar = "com.android.tools:desugar_jdk_libs:2.0.3" kotlin_gradle_plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } # https://firebase.google.com/docs/android/setup#available-libraries -google_firebase_bom = "com.google.firebase:firebase-bom:32.1.1" +google_firebase_bom = "com.google.firebase:firebase-bom:32.2.0" # AndroidX androidx_material = { module = "com.google.android.material:material", version.ref = "material" } From b9b3d6633b04627c611cbebd3807b7cb107fe9eb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 14 Jul 2023 08:16:52 +0200 Subject: [PATCH 11/42] Update dagger to v2.47 (#872) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ccfb5efdf7..ae9fd959e1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -48,7 +48,7 @@ sqldelight = "1.5.5" telephoto = "0.4.0" # DI -dagger = "2.46.1" +dagger = "2.47" anvil = "2.4.6" # Auto service From d23d6584fa144abbdccc56ec17e5199f29cc8e5b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 14 Jul 2023 09:38:20 +0000 Subject: [PATCH 12/42] Update dependency io.sentry:sentry-android to v6.25.2 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ae9fd959e1..0116c8faba 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -162,7 +162,7 @@ maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:2.0.0" # Analytics posthog = "com.posthog.android:posthog:2.0.3" -sentry_android = "io.sentry:sentry-android:6.25.1" +sentry_android = "io.sentry:sentry-android:6.25.2" matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:42b2faa417c1e95f430bf8f6e379adba25ad5ef8" # Di From fcf4454c1c3252e7837893176fed6a2c5090b038 Mon Sep 17 00:00:00 2001 From: Kat Gerasimova Date: Fri, 14 Jul 2023 11:00:02 +0100 Subject: [PATCH 13/42] Revert "Try to debug project automation column issues" (#874) This reverts commit 4dbb3994d206a3b5e8f7ad2275f437249b136755. --- .github/workflows/triage-labelled.yml | 35 +++++++++++++-------------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/.github/workflows/triage-labelled.yml b/.github/workflows/triage-labelled.yml index acbae99bb4..138708552f 100644 --- a/.github/workflows/triage-labelled.yml +++ b/.github/workflows/triage-labelled.yml @@ -17,24 +17,23 @@ jobs: project-url: https://github.com/orgs/vector-im/projects/43 github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} - move_needs_info: - name: Move triaged needs info issues on board - runs-on: ubuntu-latest - steps: - - uses: actions/add-to-project@main - id: addItem - with: - project-url: https://github.com/orgs/vector-im/projects/91 - github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} - labeled: X-Needs-Info - - name: Print itemId - run: echo ${{ steps.addItem.outputs.itemId }} - - uses: kalgurn/update-project-item-status@main - with: - project-url: https://github.com/orgs/vector-im/projects/91 - github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} - item-id: ${{ steps.addItem.outputs.itemId }} - status: "Needs info" +# move_needs_info: +# name: Move triaged needs info issues on board +# runs-on: ubuntu-latest +# steps: +# - uses: actions/add-to-project@main +# id: addItem +# with: +# project-url: https://github.com/orgs/vector-im/projects/91 +# github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} +# labeled: X-Needs-Info +# +# - uses: kalgurn/update-project-item-status@main +# with: +# project-url: https://github.com/orgs/vector-im/projects/91 +# github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} +# item-id: ${{ steps.addItem.outputs.itemId }} +# status: "Needs info" ex_plorers: name: Add labelled issues to X-Plorer project From a1ca7cf2ca9bff082bce6a940d550c1596c23b10 Mon Sep 17 00:00:00 2001 From: Marco Romano Date: Fri, 14 Jul 2023 13:32:09 +0200 Subject: [PATCH 14/42] Extract MessageComposerContext class from MessageComposerPresenter (#876) When sending "Composer" analytics from screens other than the composer's (e.g. send location from map) we need to know the composer's mode in order to properly fill the analytics event. `MessageComposerContext` hoists this state so that other presenters can also read it. Related to: https://github.com/vector-im/element-meta/issues/1674 https://github.com/vector-im/element-meta/issues/1682 --- features/messages/api/build.gradle.kts | 1 + .../messages/api/MessageComposerContext.kt | 29 ++++++++ .../MessageComposerContextImpl.kt | 34 +++++++++ .../MessageComposerPresenter.kt | 71 ++++++++++--------- .../messages/MessagesPresenterTest.kt | 2 + .../MessageComposerPresenterTest.kt | 4 +- features/messages/test/build.gradle.kts | 27 +++++++ .../test/MessageComposerContextFake.kt | 24 +++++++ .../textcomposer/MessageComposerMode.kt | 9 +++ 9 files changed, 165 insertions(+), 36 deletions(-) create mode 100644 features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessageComposerContext.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerContextImpl.kt create mode 100644 features/messages/test/build.gradle.kts create mode 100644 features/messages/test/src/main/kotlin/io/element/android/features/messages/test/MessageComposerContextFake.kt diff --git a/features/messages/api/build.gradle.kts b/features/messages/api/build.gradle.kts index d63fe04dc8..756014e97d 100644 --- a/features/messages/api/build.gradle.kts +++ b/features/messages/api/build.gradle.kts @@ -25,4 +25,5 @@ android { dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) + api(projects.libraries.textcomposer) } diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessageComposerContext.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessageComposerContext.kt new file mode 100644 index 0000000000..5a0596c7bb --- /dev/null +++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessageComposerContext.kt @@ -0,0 +1,29 @@ +/* + * 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.api + +import io.element.android.libraries.textcomposer.MessageComposerMode + +/** + * Hoist-able state of the message composer. + * + * Typical use case is inside other presenters, to know if + * the composer is in a thread, if it's editing a message, etc. + */ +interface MessageComposerContext { + val composerMode: MessageComposerMode +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerContextImpl.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerContextImpl.kt new file mode 100644 index 0000000000..73481cd617 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerContextImpl.kt @@ -0,0 +1,34 @@ +/* + * 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.messagecomposer + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.messages.api.MessageComposerContext +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.textcomposer.MessageComposerMode +import javax.inject.Inject + +@SingleIn(RoomScope::class) +@ContributesBinding(RoomScope::class) +class MessageComposerContextImpl @Inject constructor() : MessageComposerContext { + override var composerMode: MessageComposerMode by mutableStateOf(MessageComposerMode.Normal("")) + internal set +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 3ad2c497ce..f7c80b4320 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -64,6 +64,7 @@ class MessageComposerPresenter @Inject constructor( private val mediaSender: MediaSender, private val snackbarDispatcher: SnackbarDispatcher, private val analyticsService: AnalyticsService, + private val messageComposerContext: MessageComposerContextImpl, ) : Presenter { @SuppressLint("UnsafeOptInUsageError") @@ -96,14 +97,11 @@ class MessageComposerPresenter @Inject constructor( val text: MutableState = remember { mutableStateOf(StableCharSequence("")) } - val composerMode: MutableState = rememberSaveable { - mutableStateOf(MessageComposerMode.Normal("")) - } var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) } - LaunchedEffect(composerMode.value) { - when (val modeValue = composerMode.value) { + LaunchedEffect(messageComposerContext.composerMode) { + when (val modeValue = messageComposerContext.composerMode) { is MessageComposerMode.Edit -> text.value = modeValue.defaultContent.toStableCharSequence() else -> Unit } @@ -125,17 +123,21 @@ class MessageComposerPresenter @Inject constructor( is MessageComposerEvents.UpdateText -> text.value = event.text.toStableCharSequence() MessageComposerEvents.CloseSpecialMode -> { text.value = "".toStableCharSequence() - composerMode.setToNormal() + messageComposerContext.composerMode = MessageComposerMode.Normal("") } - is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(event.message, composerMode, text) + is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage( + text = event.message, + updateComposerMode = { messageComposerContext.composerMode = it }, + textState = text + ) is MessageComposerEvents.SetMode -> { - composerMode.value = event.composerMode + messageComposerContext.composerMode = event.composerMode analyticsService.capture( Composer( - inThread = false, - isEditing = composerMode.value is MessageComposerMode.Edit, - isReply = composerMode.value is MessageComposerMode.Reply, + inThread = messageComposerContext.composerMode.inThread, + isEditing = messageComposerContext.composerMode.isEditing, + isReply = messageComposerContext.composerMode.isReply, isLocation = false, ) ) @@ -171,7 +173,7 @@ class MessageComposerPresenter @Inject constructor( text = text.value, isFullScreen = isFullScreen.value, hasFocus = hasFocus.value, - mode = composerMode.value, + mode = messageComposerContext.composerMode, showAttachmentSourcePicker = showAttachmentSourcePicker, attachmentsState = attachmentsState.value, eventSink = ::handleEvents @@ -184,31 +186,30 @@ class MessageComposerPresenter @Inject constructor( } } - private fun MutableState.setToNormal() { - value = MessageComposerMode.Normal("") - } - - private fun CoroutineScope.sendMessage(text: String, composerMode: MutableState, textState: MutableState) = - launch { - val capturedMode = composerMode.value - // Reset composer right away - textState.value = "".toStableCharSequence() - composerMode.setToNormal() - when (capturedMode) { - is MessageComposerMode.Normal -> room.sendMessage(text) - is MessageComposerMode.Edit -> { - val eventId = capturedMode.eventId - val transactionId = capturedMode.transactionId - room.editMessage(eventId, transactionId, text) - } - - is MessageComposerMode.Quote -> TODO() - is MessageComposerMode.Reply -> room.replyMessage( - capturedMode.eventId, - text - ) + private fun CoroutineScope.sendMessage( + text: String, + updateComposerMode: (newComposerMode: MessageComposerMode) -> Unit, + textState: MutableState + ) = launch { + val capturedMode = messageComposerContext.composerMode + // Reset composer right away + textState.value = "".toStableCharSequence() + updateComposerMode(MessageComposerMode.Normal("")) + when (capturedMode) { + is MessageComposerMode.Normal -> room.sendMessage(text) + is MessageComposerMode.Edit -> { + val eventId = capturedMode.eventId + val transactionId = capturedMode.transactionId + room.editMessage(eventId, transactionId, text) } + + is MessageComposerMode.Quote -> TODO() + is MessageComposerMode.Reply -> room.replyMessage( + capturedMode.eventId, + text + ) } + } private fun CoroutineScope.sendAttachment( attachment: Attachment, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt index 53034131dc..990b0c5a9b 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt @@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.MessagesPresenter import io.element.android.features.messages.impl.actionlist.ActionListPresenter import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.messagecomposer.MessageComposerContextImpl import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter import io.element.android.features.messages.impl.timeline.TimelinePresenter @@ -568,6 +569,7 @@ class MessagesPresenterTest { mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom), snackbarDispatcher = SnackbarDispatcher(), analyticsService = FakeAnalyticsService(), + messageComposerContext = MessageComposerContextImpl(), ) val timelinePresenter = TimelinePresenter( timelineItemsFactory = aTimelineItemsFactory(), diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt index 97bbf925bd..742f994352 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt @@ -26,6 +26,7 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.features.analytics.test.FakeAnalyticsService import io.element.android.features.messages.impl.messagecomposer.AttachmentsState +import io.element.android.features.messages.impl.messagecomposer.MessageComposerContextImpl import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter import io.element.android.features.messages.impl.messagecomposer.MessageComposerState @@ -503,7 +504,8 @@ class MessageComposerPresenterTest { localMediaFactory, MediaSender(mediaPreProcessor, room), snackbarDispatcher, - FakeAnalyticsService() + FakeAnalyticsService(), + MessageComposerContextImpl(), ) } diff --git a/features/messages/test/build.gradle.kts b/features/messages/test/build.gradle.kts new file mode 100644 index 0000000000..27360e9567 --- /dev/null +++ b/features/messages/test/build.gradle.kts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.messages.test" +} + +dependencies { + api(projects.features.messages.api) +} diff --git a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/MessageComposerContextFake.kt b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/MessageComposerContextFake.kt new file mode 100644 index 0000000000..75c992f495 --- /dev/null +++ b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/MessageComposerContextFake.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.test + +import io.element.android.features.messages.api.MessageComposerContext +import io.element.android.libraries.textcomposer.MessageComposerMode + +class MessageComposerContextFake( + override var composerMode: MessageComposerMode = MessageComposerMode.Normal(null) +) : MessageComposerContext diff --git a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerMode.kt b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerMode.kt index f0ccc76f3c..1a9f20a060 100644 --- a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerMode.kt +++ b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerMode.kt @@ -51,4 +51,13 @@ sealed interface MessageComposerMode : Parcelable { is Quote -> eventId is Reply -> eventId } + + val isEditing: Boolean + get() = this is Edit + + val isReply: Boolean + get() = this is Reply + + val inThread: Boolean + get() = false // TODO } From 4789ad40aaea881eacbfb0b0045ac28defd9c123 Mon Sep 17 00:00:00 2001 From: Kat Gerasimova Date: Fri, 14 Jul 2023 15:20:59 +0100 Subject: [PATCH 15/42] Add fixed automation for moving issues between columns This should fix the problem we were having earlier --- .github/workflows/triage-labelled.yml | 36 ++++++++++++++------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/.github/workflows/triage-labelled.yml b/.github/workflows/triage-labelled.yml index 138708552f..5f7e6cc6ec 100644 --- a/.github/workflows/triage-labelled.yml +++ b/.github/workflows/triage-labelled.yml @@ -17,23 +17,25 @@ jobs: project-url: https://github.com/orgs/vector-im/projects/43 github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} -# move_needs_info: -# name: Move triaged needs info issues on board -# runs-on: ubuntu-latest -# steps: -# - uses: actions/add-to-project@main -# id: addItem -# with: -# project-url: https://github.com/orgs/vector-im/projects/91 -# github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} -# labeled: X-Needs-Info -# -# - uses: kalgurn/update-project-item-status@main -# with: -# project-url: https://github.com/orgs/vector-im/projects/91 -# github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} -# item-id: ${{ steps.addItem.outputs.itemId }} -# status: "Needs info" + move_needs_info: + name: Move triaged needs info issues on board + runs-on: ubuntu-latest + steps: + - uses: actions/add-to-project@main + id: addItem + with: + project-url: https://github.com/orgs/vector-im/projects/91 + github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} + labeled: X-Needs-Info + - name: Print itemId + run: echo ${{ steps.addItem.outputs.itemId }} + - uses: kalgurn/update-project-item-status@main + if: ${{ steps.addItem.outputs.itemId }} + with: + project-url: https://github.com/orgs/vector-im/projects/91 + github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} + item-id: ${{ steps.addItem.outputs.itemId }} + status: "Needs info" ex_plorers: name: Add labelled issues to X-Plorer project From 1f4b7b1d5e9eb918ad79e97b317f8251abc23cf7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 16 Jul 2023 21:03:31 +0000 Subject: [PATCH 16/42] Update rnkdsh/action-upload-diawi action to v1.5.1 --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6c0bc7f85f..59719a6de4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -45,7 +45,7 @@ jobs: name: elementx-debug path: | app/build/outputs/apk/debug/*.apk - - uses: rnkdsh/action-upload-diawi@v1.5.0 + - uses: rnkdsh/action-upload-diawi@v1.5.1 id: diawi # Do not fail the whole build if Diawi upload fails continue-on-error: true From d69dea6608bd9a9daa7219c91ec5753b3c7d2438 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Mon, 17 Jul 2023 08:05:12 +0200 Subject: [PATCH 17/42] Fix sliding sync loop restarts due to expirations Both `NotifiableEventResolver` and `DefaultNotificationDrawerManager` were creating new Rust SDK Clients while processing notifications instead of reusing the already existing one. --- .../io/element/android/appnav/RootFlowNode.kt | 2 +- .../android/appnav/di/MatrixClientsHolder.kt | 66 -------------- changelog.d/880.bugfix | 1 + .../matrix/ui/di/MatrixClientsHolder.kt | 87 +++++++++++++++++++ .../DefaultNotificationDrawerManager.kt | 4 +- .../notifications/NotifiableEventResolver.kt | 6 +- 6 files changed, 96 insertions(+), 70 deletions(-) create mode 100644 changelog.d/880.bugfix create mode 100644 libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/di/MatrixClientsHolder.kt diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 4150bcaebc..71f80dae3e 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -37,7 +37,7 @@ import com.bumble.appyx.navmodel.backstack.operation.push import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode -import io.element.android.appnav.di.MatrixClientsHolder +import io.element.android.libraries.matrix.ui.di.MatrixClientsHolder import io.element.android.appnav.intent.IntentResolver import io.element.android.appnav.intent.ResolvedIntent import io.element.android.appnav.root.RootPresenter diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt index bbb14d4d29..53a49aef9f 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt @@ -16,69 +16,3 @@ package io.element.android.appnav.di -import com.bumble.appyx.core.state.MutableSavedStateMap -import com.bumble.appyx.core.state.SavedStateMap -import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService -import io.element.android.libraries.matrix.api.core.SessionId -import kotlinx.coroutines.runBlocking -import timber.log.Timber -import java.util.concurrent.ConcurrentHashMap -import javax.inject.Inject - -private const val SAVE_INSTANCE_KEY = "io.element.android.x.di.MatrixClientsHolder.SaveInstanceKey" - -class MatrixClientsHolder @Inject constructor(private val authenticationService: MatrixAuthenticationService) { - - private val sessionIdsToMatrixClient = ConcurrentHashMap() - - fun add(matrixClient: MatrixClient) { - sessionIdsToMatrixClient[matrixClient.sessionId] = matrixClient - } - - fun removeAll() { - sessionIdsToMatrixClient.clear() - } - - fun remove(sessionId: SessionId) { - sessionIdsToMatrixClient.remove(sessionId) - } - - fun isEmpty(): Boolean = sessionIdsToMatrixClient.isEmpty() - - fun knowSession(sessionId: SessionId): Boolean = sessionIdsToMatrixClient.containsKey(sessionId) - - fun getOrNull(sessionId: SessionId): MatrixClient? { - return sessionIdsToMatrixClient[sessionId] - } - - @Suppress("UNCHECKED_CAST") - fun restore(state: SavedStateMap?) { - Timber.d("Restore state") - if (state == null || sessionIdsToMatrixClient.isNotEmpty()) return Unit.also { - Timber.w("Restore with non-empty map") - } - val sessionIds = state[SAVE_INSTANCE_KEY] as? Array - Timber.d("Restore matrix session keys = ${sessionIds?.map { it.value }}") - if (sessionIds.isNullOrEmpty()) return - // Not ideal but should only happens in case of process recreation. This ensure we restore all the active sessions before restoring the node graphs. - runBlocking { - sessionIds.forEach { sessionId -> - Timber.d("Restore matrix session: $sessionId") - authenticationService.restoreSession(sessionId) - .onSuccess { matrixClient -> - add(matrixClient) - } - .onFailure { - Timber.e("Fail to restore session") - } - } - } - } - - fun save(state: MutableSavedStateMap) { - val sessionKeys = sessionIdsToMatrixClient.keys.toTypedArray() - Timber.d("Save matrix session keys = ${sessionKeys.map { it.value }}") - state[SAVE_INSTANCE_KEY] = sessionKeys - } -} diff --git a/changelog.d/880.bugfix b/changelog.d/880.bugfix new file mode 100644 index 0000000000..b6d46820a3 --- /dev/null +++ b/changelog.d/880.bugfix @@ -0,0 +1 @@ +Fix sliding sync loop restarts due to expirations. diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/di/MatrixClientsHolder.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/di/MatrixClientsHolder.kt new file mode 100644 index 0000000000..904cbeaed2 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/di/MatrixClientsHolder.kt @@ -0,0 +1,87 @@ +/* + * 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.ui.di + +import com.bumble.appyx.core.state.MutableSavedStateMap +import com.bumble.appyx.core.state.SavedStateMap +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.api.core.SessionId +import kotlinx.coroutines.runBlocking +import timber.log.Timber +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject + +private const val SAVE_INSTANCE_KEY = "io.element.android.x.di.MatrixClientsHolder.SaveInstanceKey" + +@SingleIn(AppScope::class) +class MatrixClientsHolder @Inject constructor(private val authenticationService: MatrixAuthenticationService) { + + private val sessionIdsToMatrixClient = ConcurrentHashMap() + + fun add(matrixClient: MatrixClient) { + sessionIdsToMatrixClient[matrixClient.sessionId] = matrixClient + } + + fun removeAll() { + sessionIdsToMatrixClient.clear() + } + + fun remove(sessionId: SessionId) { + sessionIdsToMatrixClient.remove(sessionId) + } + + fun isEmpty(): Boolean = sessionIdsToMatrixClient.isEmpty() + + fun knowSession(sessionId: SessionId): Boolean = sessionIdsToMatrixClient.containsKey(sessionId) + + fun getOrNull(sessionId: SessionId): MatrixClient? { + return sessionIdsToMatrixClient[sessionId] + } + + @Suppress("UNCHECKED_CAST") + fun restore(state: SavedStateMap?) { + Timber.d("Restore state") + if (state == null || sessionIdsToMatrixClient.isNotEmpty()) return Unit.also { + Timber.w("Restore with non-empty map") + } + val sessionIds = state[SAVE_INSTANCE_KEY] as? Array + Timber.d("Restore matrix session keys = ${sessionIds?.map { it.value }}") + if (sessionIds.isNullOrEmpty()) return + // Not ideal but should only happens in case of process recreation. This ensure we restore all the active sessions before restoring the node graphs. + runBlocking { + sessionIds.forEach { sessionId -> + Timber.d("Restore matrix session: $sessionId") + authenticationService.restoreSession(sessionId) + .onSuccess { matrixClient -> + add(matrixClient) + } + .onFailure { + Timber.e("Fail to restore session") + } + } + } + } + + fun save(state: MutableSavedStateMap) { + val sessionKeys = sessionIdsToMatrixClient.keys.toTypedArray() + Timber.d("Save matrix session keys = ${sessionKeys.map { it.value }}") + state[SAVE_INSTANCE_KEY] = sessionKeys + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt index cd0274016b..c001c4ab6e 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt @@ -16,6 +16,7 @@ package io.element.android.libraries.push.impl.notifications +import io.element.android.libraries.matrix.ui.di.MatrixClientsHolder import io.element.android.libraries.androidutils.throttler.FirstThrottler import io.element.android.libraries.core.cache.CircularCache import io.element.android.libraries.core.coroutine.CoroutineDispatchers @@ -60,6 +61,7 @@ class DefaultNotificationDrawerManager @Inject constructor( private val dispatchers: CoroutineDispatchers, private val buildMeta: BuildMeta, private val matrixAuthenticationService: MatrixAuthenticationService, + private val matrixClientsHolder: MatrixClientsHolder, ) : NotificationDrawerManager { /** * Lazily initializes the NotificationState as we rely on having a current session in order to fetch the persisted queue of events. @@ -255,7 +257,7 @@ class DefaultNotificationDrawerManager @Inject constructor( val currentUser = tryOrNull( onError = { Timber.e(it, "Unable to retrieve info for user ${sessionId.value}") }, operation = { - val client = matrixAuthenticationService.restoreSession(sessionId).getOrNull() + val client = matrixClientsHolder.getOrNull(sessionId) // myUserDisplayName cannot be empty else NotificationCompat.MessagingStyle() will crash val myUserDisplayName = client?.loadUserDisplayName()?.getOrNull() ?: sessionId.value diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt index f0b70d8fac..74189276a1 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt @@ -16,6 +16,7 @@ package io.element.android.libraries.push.impl.notifications +import io.element.android.libraries.matrix.ui.di.MatrixClientsHolder import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService @@ -62,12 +63,13 @@ class NotifiableEventResolver @Inject constructor( private val matrixAuthenticationService: MatrixAuthenticationService, private val buildMeta: BuildMeta, private val clock: SystemClock, + private val matrixClientsHolder: MatrixClientsHolder, ) { suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? { // Restore session - val session = matrixAuthenticationService.restoreSession(sessionId).getOrNull() ?: return null - val notificationService = session.notificationService() + val client = matrixClientsHolder.getOrNull(sessionId) ?: return null + val notificationService = client.notificationService() val notificationData = notificationService.getNotification( userId = sessionId, roomId = roomId, From 045d957f918f9b3ffc20fef33a11890217d84021 Mon Sep 17 00:00:00 2001 From: ElementBot <110224175+ElementBot@users.noreply.github.com> Date: Mon, 17 Jul 2023 10:22:15 +0200 Subject: [PATCH 18/42] Sync Strings (#882) * Sync Strings from Localazy * Update screenshots --------- Co-authored-by: bmarty --- .../src/main/res/values-fr/translations.xml | 3 ++ .../src/main/res/values-fr/translations.xml | 16 ++++++++++ .../impl/src/main/res/values/localazy.xml | 4 +-- .../src/main/res/values-fr/translations.xml | 17 +++++++++++ .../src/main/res/values-sk/translations.xml | 7 +++++ .../impl/src/main/res/values/localazy.xml | 11 +++++++ .../src/main/res/values-fr/translations.xml | 5 ++++ .../src/main/res/values-fr/translations.xml | 5 ++-- .../impl/src/main/res/values/localazy.xml | 2 +- .../src/main/res/values-fr/translations.xml | 9 ++++++ .../src/main/res/values-sk/translations.xml | 5 ++++ .../impl/src/main/res/values/localazy.xml | 5 ++++ .../src/main/res/values-fr/translations.xml | 4 +-- .../impl/src/main/res/values/localazy.xml | 2 +- .../src/main/res/values-de/translations.xml | 5 ++-- .../src/main/res/values-fr/translations.xml | 30 +++++++++++++++++++ .../src/main/res/values-sk/translations.xml | 6 ++-- .../src/main/res/values/localazy.xml | 6 ++-- ...ewDarkPreview_0_null_0,NEXUS_5,1.0,en].png | 4 +-- ...wLightPreview_0_null_0,NEXUS_5,1.0,en].png | 4 +-- ...ewDarkPreview_0_null_0,NEXUS_5,1.0,en].png | 4 +-- ...ewDarkPreview_0_null_1,NEXUS_5,1.0,en].png | 4 +-- ...ewDarkPreview_0_null_2,NEXUS_5,1.0,en].png | 4 +-- ...wLightPreview_0_null_0,NEXUS_5,1.0,en].png | 4 +-- ...wLightPreview_0_null_1,NEXUS_5,1.0,en].png | 4 +-- ...wLightPreview_0_null_2,NEXUS_5,1.0,en].png | 4 +-- 26 files changed, 141 insertions(+), 33 deletions(-) diff --git a/features/invitelist/impl/src/main/res/values-fr/translations.xml b/features/invitelist/impl/src/main/res/values-fr/translations.xml index a30aef7b1d..677fadd539 100644 --- a/features/invitelist/impl/src/main/res/values-fr/translations.xml +++ b/features/invitelist/impl/src/main/res/values-fr/translations.xml @@ -1,6 +1,9 @@ + "Voulez-vous vraiment refuser l‘invitation à rejoindre %1$s ?" "Refuser l\'invitation" + "Voulez-vous vraiment refuser ce chat privé avec %1$s ?" "Refuser le chat" "Aucune invitation" + "%1$s (%2$s) vous a invité" diff --git a/features/login/impl/src/main/res/values-fr/translations.xml b/features/login/impl/src/main/res/values-fr/translations.xml index 85a89ffef2..c4cd027498 100644 --- a/features/login/impl/src/main/res/values-fr/translations.xml +++ b/features/login/impl/src/main/res/values-fr/translations.xml @@ -3,6 +3,8 @@ "Changer de fournisseur" "Continuer" "Adresse du serveur d\'accueil" + "Entrez un mot clé de recherche ou un nom de domaine." + "Rechercher une entreprise, une communauté ou un serveur privé." "Trouver un fournisseur de services" "C\'est ici que vos conversations seront stockées - tout comme vous utiliseriez un fournisseur de messagerie pour conserver vos e-mails." "Vous êtes sur le point de vous connecter à %s" @@ -23,9 +25,23 @@ "Le serveur domestique sélectionné ne prend pas en charge le mot de passe ou la connexion OIDC. Contactez votre administrateur ou choisissez un autre serveur domestique." "Saisir vos informations personnelles" "Heureux de vous revoir!" + "Se connecter à %1$s" + "Changer de fournisseur de compte" + "Un serveur privé pour les employés d’Element." + "Matrix est un réseau ouvert de communication sécurisée et décentralisée." + "C\'est là que vos conversations seront conservées — de la même manière que votre service d’e-mail habituel conserverait vos e-mails." + "Vous allez vous connecter à %1$s" + "Vous allez créer un compte sur %1$s" + "Il y a une forte demande pour %1$s sur %2$s en ce moment. Rouvrez l’app dans quelques jours et réessayez. + +Merci de votre patience !" + "Bienvenue sur %1$s !" + "Vous y êtes presque." + "Vous y êtes." "Continuer" "Sélectionnez votre serveur" "Mot de passe" "Continuer" + "Matrix est un réseau ouvert de communication sécurisée et décentralisée." "Nom d\'utilisateur" diff --git a/features/login/impl/src/main/res/values/localazy.xml b/features/login/impl/src/main/res/values/localazy.xml index 891786b3a2..893f1674a3 100644 --- a/features/login/impl/src/main/res/values/localazy.xml +++ b/features/login/impl/src/main/res/values/localazy.xml @@ -6,9 +6,9 @@ "Enter a search term or a domain address." "Search for a company, community, or private server." "Find an account provider" - "This is where you conversations will live — just like you would use an email provider to keep your emails." + "This is where your conversations will live — just like you would use an email provider to keep your emails." "You’re about to sign in to %s" - "This is where you conversations will live — just like you would use an email provider to keep your emails." + "This is where your conversations will live — just like you would use an email provider to keep your emails." "You’re about to create an account on %s" "Matrix.org is an open network for secure, decentralized communication." "Other" diff --git a/features/messages/impl/src/main/res/values-fr/translations.xml b/features/messages/impl/src/main/res/values-fr/translations.xml index 7e79f98f51..03dcaffac7 100644 --- a/features/messages/impl/src/main/res/values-fr/translations.xml +++ b/features/messages/impl/src/main/res/values-fr/translations.xml @@ -4,10 +4,27 @@ "%1$d changement dans la conversation" "%1$d changements dans la conversation" + + + "%1$d de plus" + "Appareil photo" "Prendre une photo" "Enregistrer une vidéo" "Pièce-jointe" "Gallerie photo et vidéo" + "L’historique des messages n’est pas disponible actuellement dans ce salon" + "Impossible de récupérer les détails de l’utilisateur" + "Souhaitez-vous les inviter à revenir ?" + "Vous êtes seul dans ce chat" + "Message copié" + "Vous n‘avez pas le droit de poster dans ce salon" + "Afficher moins" + "Afficher plus" + "Renvoyer" + "Votre message n\'a pas pu être envoyé" + "Ajouter un emoji" + "Montrer moins" + "Échec du traitement du média avant son envoi, veuillez réessayer." "Supprimer" diff --git a/features/messages/impl/src/main/res/values-sk/translations.xml b/features/messages/impl/src/main/res/values-sk/translations.xml index fcd8c0ca82..39b7e974be 100644 --- a/features/messages/impl/src/main/res/values-sk/translations.xml +++ b/features/messages/impl/src/main/res/values-sk/translations.xml @@ -5,6 +5,11 @@ "%1$d zmeny miestnosti" "%1$d zmien miestnosti" + + + "%1$d ďalšie" + "%1$d ďalších" + "Kamera" "Odfotiť" "Nahrať video" @@ -21,6 +26,8 @@ "Zobraziť viac" "Odoslať znova" "Vašu správu sa nepodarilo odoslať" + "Pridať emoji" + "Zobraziť menej" "Nepodarilo sa spracovať médiá na odoslanie, skúste to prosím znova." "Odstrániť" diff --git a/features/messages/impl/src/main/res/values/localazy.xml b/features/messages/impl/src/main/res/values/localazy.xml index 03ac9ea46b..6303589aa2 100644 --- a/features/messages/impl/src/main/res/values/localazy.xml +++ b/features/messages/impl/src/main/res/values/localazy.xml @@ -19,6 +19,17 @@ "You are alone in this chat" "Message copied" "You do not have permission to post to this room" + "Allow custom setting" + "Turning this on will override your default setting" + "Notify me in this chat for" + "You can change it in your %1$s." + "global settings" + "Default setting" + "An error occurred while loading notification settings." + "Failed restoring the default mode, please try again." + "Failed setting the mode, please try again." + "All messages" + "Mentions and Keywords only" "Show less" "Show more" "Send again" diff --git a/features/onboarding/impl/src/main/res/values-fr/translations.xml b/features/onboarding/impl/src/main/res/values-fr/translations.xml index 018bf21379..5ed96ebf60 100644 --- a/features/onboarding/impl/src/main/res/values-fr/translations.xml +++ b/features/onboarding/impl/src/main/res/values-fr/translations.xml @@ -1,5 +1,10 @@ + "Se connecter manuellement" + "Se connecter avec un code QR" + "Créer un compte" + "Communiquer et collaborer en toute sécurité" + "Bienvenue dans l’Element le plus rapide de tous les temps. Surpuissant pour plus de vitesse et de simplicité." "Bienvenue dans %1$s. Affiné pour plus de rapidité et de simplicité." "Soyez dans votre Element" diff --git a/features/rageshake/impl/src/main/res/values-fr/translations.xml b/features/rageshake/impl/src/main/res/values-fr/translations.xml index 84b3ad3386..bf6ad2d215 100644 --- a/features/rageshake/impl/src/main/res/values-fr/translations.xml +++ b/features/rageshake/impl/src/main/res/values-fr/translations.xml @@ -2,13 +2,14 @@ "Joindre une capture d\'écran" "Vous pouvez me contacter si vous avez des questions complémentaires" + "Me contacter" "Modifier la capture d\'écran" "S\'il vous plait, veuillez décrire le bogue. Qu\'avez-vous fait ? À quoi vous attendiez-vous ? Que s\'est-il réellement passé. Veuillez ajouter le plus de détails possible." "Décrire le bogue" "Si possible, veuillez rédiger la description en anglais." "Envoyer des journaux d’incident" - "Envoyer le journal pour nous aider" + "Autoriser à inclure les journaux techniques" "Envoyer une capture d’écran" - "Pour vérifier que les choses fonctionnent comme prévu, les journaux seront envoyés avec votre message. Ceux-ci seront privées. Pour simplement envoyer votre message, désactivez ce paramètre." + "Pour vérifier que les choses fonctionnent comme prévu, des journaux techniques seront envoyés avec votre message. Pour l’envoyer sans ces journaux, désactivez ce paramètre." "%1$s a planté la dernière fois qu\'il a été utilisé. Souhaitez-vous partager un rapport de crash avec nous ?" diff --git a/features/rageshake/impl/src/main/res/values/localazy.xml b/features/rageshake/impl/src/main/res/values/localazy.xml index 4e7fe82ea6..fb02c93780 100644 --- a/features/rageshake/impl/src/main/res/values/localazy.xml +++ b/features/rageshake/impl/src/main/res/values/localazy.xml @@ -1,7 +1,7 @@ "Attach screenshot" - "You may contact me if you have any follow up questions" + "You may contact me if you have any follow up questions." "Contact me" "Edit screenshot" "Please describe the bug. What did you do? What did you expect to happen? What actually happened. Please go into as much detail as you can." diff --git a/features/roomdetails/impl/src/main/res/values-fr/translations.xml b/features/roomdetails/impl/src/main/res/values-fr/translations.xml index c3b2aa7883..ee34445805 100644 --- a/features/roomdetails/impl/src/main/res/values-fr/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-fr/translations.xml @@ -5,10 +5,19 @@ "%1$d membres" "Définir un sujet" + "Déjà membre" + "Déjà invité(e)" + "Modifier le salon" + "Une erreur inconnue s’est produite et les informations n’ont pas pu être modifiées." + "Impossible de mettre à jour le salon" "Les messages sont sécurisés par des cadenas numériques. Seuls vous et les destinataires possédez les clés uniques pour les déverrouiller." "Chiffrement des messages activé" "Inviter des personnes" + "Notifications" + "Nom du salon" "Partager le salon" + "Mise à jour du salon…" + "En attente" "Bloquer" "Les utilisateurs bloqués ne pourront pas vous envoyer de messages et tous leurs messages seront masqués. Vous pouvez annuler cette action à tout moment." "Bloquer l\'utilisateur" diff --git a/features/roomdetails/impl/src/main/res/values-sk/translations.xml b/features/roomdetails/impl/src/main/res/values-sk/translations.xml index 3f20205458..1d744fba30 100644 --- a/features/roomdetails/impl/src/main/res/values-sk/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-sk/translations.xml @@ -13,7 +13,12 @@ "Nepodarilo sa aktualizovať miestnosť" "Správy sú zabezpečené zámkami. Jedine vy a príjemcovia máte jedinečné kľúče na ich odomknutie." "Šifrovanie správ je zapnuté" + "Pri načítaní nastavení oznámení došlo k chybe." + "Nepodarilo sa stlmiť túto miestnosť, skúste to prosím znova." + "Nepodarilo sa zrušiť stlmenie tejto miestnosti, skúste to prosím znova." "Pozvať ľudí" + "Vlastné" + "Predvolené" "Oznámenia" "Názov miestnosti" "Zdieľať miestnosť" diff --git a/features/roomdetails/impl/src/main/res/values/localazy.xml b/features/roomdetails/impl/src/main/res/values/localazy.xml index 2bd6a259aa..158ba386b4 100644 --- a/features/roomdetails/impl/src/main/res/values/localazy.xml +++ b/features/roomdetails/impl/src/main/res/values/localazy.xml @@ -12,7 +12,12 @@ "Unable to update room" "Messages are secured with locks. Only you and the recipients have the unique keys to unlock them." "Message encryption enabled" + "An error occurred when loading notification settings." + "Failed muting this room, please try again." + "Failed unmuting this room, please try again." "Invite people" + "Custom" + "Default" "Notifications" "Room name" "Share room" diff --git a/features/roomlist/impl/src/main/res/values-fr/translations.xml b/features/roomlist/impl/src/main/res/values-fr/translations.xml index 5d9abad583..a5217e1ad5 100644 --- a/features/roomlist/impl/src/main/res/values-fr/translations.xml +++ b/features/roomlist/impl/src/main/res/values-fr/translations.xml @@ -2,6 +2,6 @@ "Créer une nouvelle conversation ou un nouveau salon" "Tous les chats" - "Il semblerait que vous utilisiez un nouvel appareil. Vérifiez que vous êtes bien autorisé à accéder à vos messages chiffrés." - "Accédez à l\'historique de vos messages" + "Il semblerait que vous utilisiez un nouvel appareil. Lancez la vérification avec un autre appareil pour accéder à vos messages chiffrés à l’avenir." + "Vérifier que c’est bien vous" diff --git a/libraries/push/impl/src/main/res/values/localazy.xml b/libraries/push/impl/src/main/res/values/localazy.xml index 0d66ac1336..987728304a 100644 --- a/libraries/push/impl/src/main/res/values/localazy.xml +++ b/libraries/push/impl/src/main/res/values/localazy.xml @@ -4,7 +4,6 @@ "Listening for events" "Noisy notifications" "Silent notifications" - "Notification" "** Failed to send - please open room" "Join" "Reject" @@ -48,5 +47,6 @@ "Background synchronization" "Google Services" "No valid Google Play Services found. Notifications may not work properly." + "Notification" "Quick reply" diff --git a/libraries/ui-strings/src/main/res/values-de/translations.xml b/libraries/ui-strings/src/main/res/values-de/translations.xml index 5c4b92c95d..768097f37c 100644 --- a/libraries/ui-strings/src/main/res/values-de/translations.xml +++ b/libraries/ui-strings/src/main/res/values-de/translations.xml @@ -140,10 +140,9 @@ "Reisen & Orte" "Symbole" "Fehler beim Erstellen des Permalinks" - "Element konnte die Karte nicht laden. Bitte versuche es später erneut." + "%1$s konnte die Karte nicht laden. Bitte versuche es später erneut." "Fehler beim Laden der Nachrichten" - "Element konnte nicht auf deinen Standort zugreifen. Bitte versuche es später erneut." - "Element hat keine Berechtigung, auf deinen Standort zuzugreifen. Du kannst den Zugriff unter Einstellungen > Standort aktivieren." + "%1$s konnte nicht auf deinen Standort zugreifen. Bitte versuche es später erneut." "Einige Nachrichten wurden nicht gesendet" "Entschuldigung, ein Fehler ist aufgetreten." "🔐️ Besuchen Sie mich auf %1$s" diff --git a/libraries/ui-strings/src/main/res/values-fr/translations.xml b/libraries/ui-strings/src/main/res/values-fr/translations.xml index 18b9318c1c..2ff0ae3914 100644 --- a/libraries/ui-strings/src/main/res/values-fr/translations.xml +++ b/libraries/ui-strings/src/main/res/values-fr/translations.xml @@ -65,12 +65,14 @@ "Statistiques d\'utilisation" "Audio" "Bulles" + "Copyright" "Création du salon…" "Le salon a été quitté" "Erreur de déchiffrement" "Options de développement" "(modifié)" "Modification en cours" + "* %1$s %2$s" "Chiffrement activé" "Erreur" "Fichier" @@ -79,12 +81,14 @@ "GIF" "Image" "Nous ne pouvons pas vérifier le Matrix ID de cet utilisateur. Cette invitation pourrait être envoyée dans le vide." + "Quitter le salon" "Lien copié dans le presse-papiers" "Chargement…" "Message" "Mode d\'affichage des messages" "Message supprimé" "Moderne" + "Sourdine" "Aucun résultat" "Hors ligne" "Mot de passe" @@ -106,15 +110,19 @@ "Serveur non pris en charge" "URL du serveur" "Paramètres" + "Position partagée" + "Démarrage du chat…" "Autocollant" "Succès" "Suggestions" "Synchronisation" + "Mentions tierces" "Sujet" "De quoi parle ce salon ?" "Échec de déchiffrement" "Nous n\'avons pas réussi à envoyer des invitations à un ou plusieurs utilisateurs." "Impossible d\'envoyer une ou plusieurs invitations" + "Réactiver" "Événement non pris en charge" "Nom d\'utilisateur" "Vérification annulée" @@ -132,9 +140,11 @@ "Voyages & lieux" "Symboles" "Échec de la création du permalien" + "%1$s n’a pas pu charger la carte. Veuillez réessayer plus tard." "Échec du chargement des messages" "Certains messages n\'ont pas été envoyés" "Désolé, une erreur est survenue." + "🔐️ Rejoignez-moi sur %1$s" "Salut, parle-moi sur %1$s : %2$s" "Êtes-vous sûr de vouloir quitter ce salon ? Vous êtes la seule personne ici. Si vous partez, personne ne pourra plus rejoindre ce salon, y compris vous." "Êtes-vous sûr de vouloir quitter ce salon ? Ce salon n\'est pas public et vous ne pourrez pas le rejoindre sans invitation." @@ -152,7 +162,27 @@ "Ceci est le début de cette conversation." "Nouveau" "Partager les statistiques d\'utilisation" + "Impossible de sélectionner un média, veuillez réessayer." + "Échec du traitement du média avant son envoi, veuillez réessayer." + "Impossible d’envoyer le média, veuillez réessayer." + "Ce processus n’a besoin d’être fait qu’une seule fois, merci de patienter." + "Configuration de votre compte." + "Activer les notifications sur cet appareil" + "paramètres système" + "Notifications système désactivées" + "Notifications" "Cochez si vous souhaitez masquer tous les messages actuels et futurs de cet utilisateur." + "Partage de position" + "Partager ma position" + "Ouvrir dans Apple Maps" + "Ouvrir dans Google Maps" + "Ouvrir dans OpenStreetMap" + "Partager cette position" + "L’historique des messages pour les salons chiffrés ne sera pas disponible dans cette mise à jour." + "Nous serions ravis d’avoir votre avis, n’hésitez pas à nous le partager via la page des paramètres." + "C’est parti !" + "Voici ce qu’il faut savoir :" + "Bienvenue sur %1$s !" "Rageshake" "Seuil de détection" "Général" diff --git a/libraries/ui-strings/src/main/res/values-sk/translations.xml b/libraries/ui-strings/src/main/res/values-sk/translations.xml index a6684f8781..d75afca6a4 100644 --- a/libraries/ui-strings/src/main/res/values-sk/translations.xml +++ b/libraries/ui-strings/src/main/res/values-sk/translations.xml @@ -140,10 +140,10 @@ "Cestovanie a miesta" "Symboly" "Nepodarilo sa vytvoriť trvalý odkaz" - "Element nedokázal načítať mapu. Skúste to prosím neskôr." + "%1$s nedokázal načítať mapu. Skúste to prosím neskôr." "Načítanie správ zlyhalo" - "Element nemohol získať prístup k vašej polohe. Skúste to prosím neskôr." - "Element nemá povolenie na prístup k vašej polohe. Prístup môžete povoliť v Nastavenia > Poloha" + "%1$s nemohol získať prístup k vašej polohe. Skúste to prosím neskôr." + "%1$s nemá povolenie na prístup k vašej polohe. Prístup môžete povoliť v Nastavenia > Poloha" "Niektoré správy neboli odoslané" "Prepáčte, vyskytla sa chyba" "🔐️ Pripojte sa ku mne na %1$s" diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 81b76f988e..94dceaaa7e 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -140,10 +140,10 @@ "Travel & Places" "Symbols" "Failed creating the permalink" - "Element could not load the map. Please try again later." + "%1$s could not load the map. Please try again later." "Failed loading messages" - "Element could not access your location. Please try again later." - "Element does not have permission to access your location. You can enable access in Settings > Location" + "%1$s could not access your location. Please try again later." + "%1$s does not have permission to access your location. You can enable access in Settings > Location" "Some messages have not been sent" "Sorry, an error occurred" "🔐️ Join me on %1$s" diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.confirmaccountprovider_null_DefaultGroup_ConfirmAccountProviderViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.confirmaccountprovider_null_DefaultGroup_ConfirmAccountProviderViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png index 14bfd18d9d..363280c1fa 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.confirmaccountprovider_null_DefaultGroup_ConfirmAccountProviderViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.confirmaccountprovider_null_DefaultGroup_ConfirmAccountProviderViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:59c58a7958303eeaf15640fdd6ce4d156cf12a14f680ef8099ace296c17e9e8b -size 36849 +oid sha256:52a5e65b23072ccfdc7220daca437e93342c04cee91cd971ba8d13872968ae7d +size 36962 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.confirmaccountprovider_null_DefaultGroup_ConfirmAccountProviderViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.confirmaccountprovider_null_DefaultGroup_ConfirmAccountProviderViewLightPreview_0_null_0,NEXUS_5,1.0,en].png index 364da412be..232ce0d49c 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.confirmaccountprovider_null_DefaultGroup_ConfirmAccountProviderViewLightPreview_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.confirmaccountprovider_null_DefaultGroup_ConfirmAccountProviderViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dd46f692082af30abc98cf8c7ba35e55d4f1dde2e9c5f036012de5bd41a96f74 -size 38967 +oid sha256:ea4a9e4b5488e925652ff747997dcbb58c55dd126c7b81f5222c30291270d7e6 +size 39147 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png index 64a10ed42f..0a43709e00 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:549e9a954cd4b3cb9aef6f2d5f9de45f81be68e5b779ec72552650182f418b08 -size 64611 +oid sha256:07e1283e5ac86cf9c77b0695a6622682e7257abce1e7787440f50c8f42ca0291 +size 64632 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png index 2a5898b842..e26b93adf7 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dc3d433d585c87e869c3c426759d62f215f544adda2ed4a6dfa4ce462c3a8a7a -size 200136 +oid sha256:dac098de353f8dce2d8eea77bccdcebf997c642f05afaa15c02a9bcf230fa37a +size 200158 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png index e490d5cfbe..da4cf4461a 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5818b25d9c382f25f0b8028d51c87cfe8f94deabbd32b739c9ba851b1ed40a87 -size 54996 +oid sha256:c4b2f7af207e8d2f951474575de12969e0fdf0831ce68fdfde5b67b364c67156 +size 55013 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewLightPreview_0_null_0,NEXUS_5,1.0,en].png index d0b105b609..5ebb85150a 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewLightPreview_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:efee5c2bdb9fe772bd5e40485cb2f889658e9c523a8b4789f9e271ef4e3f3cc5 -size 67437 +oid sha256:91fcd5dc74788d4f597dfc265a1d6a7511e5f4db47323d930f7c54c7df7d62bd +size 67464 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewLightPreview_0_null_1,NEXUS_5,1.0,en].png index 91a39fafab..b999544c27 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewLightPreview_0_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4bab9b53b1c46abe20a65e0ae77893d7a59046f5b7e45d95a8b15ac5414b68e4 -size 204330 +oid sha256:3583a0758b9c02c7e165dc593f7a090a54d70bcfa0f39853ba3bddb42850d404 +size 204354 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewLightPreview_0_null_2,NEXUS_5,1.0,en].png index 0b131a2205..784d1dbe36 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewLightPreview_0_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:945a3683f65a71f2e2f055f760d505387b22e4ca754cb573e11c8c9890c86b88 -size 59066 +oid sha256:3dac3ed1e6c46265e8b0f7bb479042271668178ff2ad34baae3cb2d777c7902a +size 59091 From 323a84db37096e9bcd7273a639928026fa4a497a Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Mon, 17 Jul 2023 10:55:51 +0200 Subject: [PATCH 19/42] Disable Paparazzi tasks when Kover is running. (#884) * Disable Paparazzi tasks when Kover is running. It allows us to split the test jobs between unit tests, screenshot test and coverage reports. * Move Sonar upload to the quality workflow, since we have no lint info in tests. --- .github/workflows/nightlyReports.yml | 7 +++++-- .github/workflows/quality.yml | 6 ++++++ .github/workflows/tests.yml | 14 +++++--------- build.gradle.kts | 7 ------- tests/uitests/build.gradle.kts | 10 ++++++++++ 5 files changed, 26 insertions(+), 18 deletions(-) diff --git a/.github/workflows/nightlyReports.yml b/.github/workflows/nightlyReports.yml index ef2c294f2e..89c992e2c9 100644 --- a/.github/workflows/nightlyReports.yml +++ b/.github/workflows/nightlyReports.yml @@ -29,8 +29,11 @@ jobs: - name: ⚙️ Run unit tests, debug and release run: ./gradlew test $CI_GRADLE_ARG_PROPERTIES - - name: 📈 Run screenshot tests, generate kover report and verify coverage - run: ./gradlew verifyPaparazziDebug koverMergedReport koverMergedVerify $CI_GRADLE_ARG_PROPERTIES -Pci-build=true + - name: 📸 Run screenshot tests + run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES + + - name: 📈 Generate kover report and verify coverage + run: ./gradlew koverMergedReport koverMergedVerify $CI_GRADLE_ARG_PROPERTIES -Pci-build=true - name: ✅ Upload kover report if: always() diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index e219067e9c..9dfe7dc06b 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -51,6 +51,12 @@ jobs: name: linting-report path: | */build/reports/**/*.* + - name: 🔊 Publish results to Sonar + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + ORG_GRADLE_PROJECT_SONAR_LOGIN: ${{ secrets.SONAR_TOKEN }} + if: ${{ always() && env.SONAR_TOKEN != '' && env.ORG_GRADLE_PROJECT_SONAR_LOGIN != '' }} + run: ./gradlew sonar $CI_GRADLE_ARG_PROPERTIES - name: Prepare Danger if: always() run: | diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ffdd6f5ad7..e5a35da4c9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -40,8 +40,11 @@ jobs: - name: ⚙️ Run unit tests, debug and release run: ./gradlew test $CI_GRADLE_ARG_PROPERTIES - - name: 📈 Run screenshot tests, generate kover report and verify coverage - run: ./gradlew verifyPaparazziDebug koverMergedReport koverMergedVerify $CI_GRADLE_ARG_PROPERTIES -Pci-build=true + - name: 📸 Run screenshot tests + run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES + + - name: 📈Generate kover report and verify coverage + run: ./gradlew koverMergedReport koverMergedVerify $CI_GRADLE_ARG_PROPERTIES -Pci-build=true - name: 🚫 Upload kover failed coverage reports if: failure() @@ -80,13 +83,6 @@ jobs: **/out/failures/ **/build/reports/tests/*UnitTest/ - - name: 🔊 Publish results to Sonar - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - ORG_GRADLE_PROJECT_SONAR_LOGIN: ${{ secrets.SONAR_TOKEN }} - if: ${{ always() && env.SONAR_TOKEN != '' && env.ORG_GRADLE_PROJECT_SONAR_LOGIN != '' }} - run: ./gradlew sonar $CI_GRADLE_ARG_PROPERTIES - # https://github.com/codecov/codecov-action - name: ☂️ Upload coverage reports to codecov if: always() diff --git a/build.gradle.kts b/build.gradle.kts index f8eda34bd8..e9adcdee63 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -283,13 +283,6 @@ koverMerged { } } -// Make Kover depend on Paparazzi -tasks.whenTaskAdded { - if (name.startsWith("koverMerged")) { - dependsOn(":tests:uitests:verifyPaparazziDebug") - } -} - // When running on the CI, run only debug test variants val ciBuildProperty = "ci-build" val isCiBuild = if (project.hasProperty(ciBuildProperty)) { diff --git a/tests/uitests/build.gradle.kts b/tests/uitests/build.gradle.kts index 13f43b22d4..e60a7366dc 100644 --- a/tests/uitests/build.gradle.kts +++ b/tests/uitests/build.gradle.kts @@ -28,6 +28,16 @@ android { namespace = "io.element.android.tests.uitests" } +// Workaround: `kover` tasks somehow trigger the screenshot tests with a broken configuration, removing +// any previous test results and not creating new ones. This is a workaround to disable the screenshot tests +// when the `kover` tasks are detected. +tasks.withType() { + if (project.gradle.startParameter.taskNames.any { it.contains("kover", ignoreCase = true) }) { + println("WARNING: Kover task detected, disabling screenshot test task $name.") + isEnabled = false + } +} + dependencies { testImplementation(libs.test.junit) testImplementation(libs.test.parameter.injector) From ded1518f0b3e2de07e96a9a4d0c031fd273b17ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Mon, 17 Jul 2023 15:05:13 +0200 Subject: [PATCH 20/42] Try to centralise session restoration through `MatrixClientsHolder` --- .../io/element/android/appnav/RootFlowNode.kt | 10 ++--- .../impl/auth/AuthenticationException.kt | 2 +- .../matrix/ui/di/MatrixClientsHolder.kt | 37 ++++++++++++++----- .../DefaultNotificationDrawerManager.kt | 6 +-- .../notifications/NotifiableEventResolver.kt | 2 +- 5 files changed, 36 insertions(+), 21 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 71f80dae3e..adc1c3384d 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -145,14 +145,10 @@ class RootFlowNode @AssistedInject constructor( onFailure: () -> Unit = {}, onSuccess: (SessionId) -> Unit = {}, ) { - // If the session is already known it'll be restored by the node hierarchy - if (matrixClientsHolder.knowSession(sessionId)) { - Timber.v("Session $sessionId already alive, no need to restore.") - return + runCatching { + matrixClientsHolder.requireSession(sessionId) } - authenticationService.restoreSession(sessionId) - .onSuccess { matrixClient -> - matrixClientsHolder.add(matrixClient) + .onSuccess { Timber.v("Succeed to restore session $sessionId") onSuccess(sessionId) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt index c264a95f67..26a9fba38f 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt @@ -35,6 +35,6 @@ fun Throwable.mapAuthenticationException(): Throwable { is RustAuthenticationException.OidcNotSupported -> AuthenticationException.OidcError("OidcNotSupported", message!!) */ - else -> this + else -> AuthenticationException.Generic(this.message ?: "Unknown error") } } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/di/MatrixClientsHolder.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/di/MatrixClientsHolder.kt index 904cbeaed2..a6db7a1c9e 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/di/MatrixClientsHolder.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/di/MatrixClientsHolder.kt @@ -21,12 +21,16 @@ import com.bumble.appyx.core.state.SavedStateMap import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.SingleIn import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.auth.AuthenticationException import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.core.SessionId import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import timber.log.Timber import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject +import kotlin.jvm.Throws private const val SAVE_INSTANCE_KEY = "io.element.android.x.di.MatrixClientsHolder.SaveInstanceKey" @@ -34,8 +38,9 @@ private const val SAVE_INSTANCE_KEY = "io.element.android.x.di.MatrixClientsHold class MatrixClientsHolder @Inject constructor(private val authenticationService: MatrixAuthenticationService) { private val sessionIdsToMatrixClient = ConcurrentHashMap() + private val restoreMutex = Mutex() - fun add(matrixClient: MatrixClient) { + private fun add(matrixClient: MatrixClient) { sessionIdsToMatrixClient[matrixClient.sessionId] = matrixClient } @@ -55,6 +60,27 @@ class MatrixClientsHolder @Inject constructor(private val authenticationService: return sessionIdsToMatrixClient[sessionId] } + @Throws(AuthenticationException::class) + suspend fun requireSession(sessionId: SessionId): MatrixClient { + return restoreMutex.withLock { + when (val matrixClient = sessionIdsToMatrixClient[sessionId]) { + null -> restore(sessionId).getOrThrow() + else -> matrixClient + } + } + } + + private suspend fun restore(sessionId: SessionId): Result { + Timber.d("Restore matrix session: $sessionId") + return authenticationService.restoreSession(sessionId) + .onSuccess { matrixClient -> + add(matrixClient) + } + .onFailure { + Timber.e("Fail to restore session") + } + } + @Suppress("UNCHECKED_CAST") fun restore(state: SavedStateMap?) { Timber.d("Restore state") @@ -67,14 +93,7 @@ class MatrixClientsHolder @Inject constructor(private val authenticationService: // Not ideal but should only happens in case of process recreation. This ensure we restore all the active sessions before restoring the node graphs. runBlocking { sessionIds.forEach { sessionId -> - Timber.d("Restore matrix session: $sessionId") - authenticationService.restoreSession(sessionId) - .onSuccess { matrixClient -> - add(matrixClient) - } - .onFailure { - Timber.e("Fail to restore session") - } + restore(sessionId) } } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt index c001c4ab6e..dc484c29a3 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt @@ -257,11 +257,11 @@ class DefaultNotificationDrawerManager @Inject constructor( val currentUser = tryOrNull( onError = { Timber.e(it, "Unable to retrieve info for user ${sessionId.value}") }, operation = { - val client = matrixClientsHolder.getOrNull(sessionId) + val client = matrixClientsHolder.requireSession(sessionId) // myUserDisplayName cannot be empty else NotificationCompat.MessagingStyle() will crash - val myUserDisplayName = client?.loadUserDisplayName()?.getOrNull() ?: sessionId.value - val userAvatarUrl = client?.loadUserAvatarURLString()?.getOrNull() + val myUserDisplayName = client.loadUserDisplayName().getOrNull() ?: sessionId.value + val userAvatarUrl = client.loadUserAvatarURLString().getOrNull() MatrixUser( userId = sessionId, displayName = myUserDisplayName, diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt index 74189276a1..ad55bddb54 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt @@ -68,7 +68,7 @@ class NotifiableEventResolver @Inject constructor( suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? { // Restore session - val client = matrixClientsHolder.getOrNull(sessionId) ?: return null + val client = matrixClientsHolder.requireSession(sessionId) val notificationService = client.notificationService() val notificationData = notificationService.getNotification( userId = sessionId, From c70dedba13406c165aca282b2e4bb542d892902b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 17 Jul 2023 15:50:08 +0200 Subject: [PATCH 21/42] Map ClientException. --- .../matrix/api/exception/ClientException.kt | 22 +++++++++++++++ .../auth/RustMatrixAuthenticationService.kt | 3 ++- .../matrix/impl/exception/ClientException.kt | 27 +++++++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/ClientException.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/exception/ClientException.kt diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/ClientException.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/ClientException.kt new file mode 100644 index 0000000000..52dbd2eb12 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/ClientException.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.matrix.api.exception + +sealed class ClientException(message: String) : Exception(message) { + class Generic(message: String) : ClientException(message) + class Other(message: String) : ClientException(message) +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt index bdb87b298c..5b8d175d40 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt @@ -29,6 +29,7 @@ import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails import io.element.android.libraries.matrix.api.auth.OidcDetails import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.impl.RustMatrixClient +import io.element.android.libraries.matrix.impl.exception.mapClientException import io.element.android.libraries.network.useragent.UserAgentProvider import io.element.android.libraries.sessionstorage.api.SessionData import io.element.android.libraries.sessionstorage.api.SessionStore @@ -94,7 +95,7 @@ class RustMatrixAuthenticationService @Inject constructor( throw IllegalStateException("No session to restore with id $sessionId") } }.mapFailure { failure -> - failure.mapAuthenticationException() + failure.mapClientException() } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/exception/ClientException.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/exception/ClientException.kt new file mode 100644 index 0000000000..a72755d129 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/exception/ClientException.kt @@ -0,0 +1,27 @@ +/* + * 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.exception + +import io.element.android.libraries.matrix.api.exception.ClientException +import org.matrix.rustcomponents.sdk.ClientException as RustClientException + +fun Throwable.mapClientException(): Throwable { + return when (this) { + is RustClientException.Generic -> ClientException.Generic(msg) + else -> ClientException.Other(message ?: "Unknown error") + } +} From a852465554e65860941922435b01c15dc8403861 Mon Sep 17 00:00:00 2001 From: Marco Romano Date: Mon, 17 Jul 2023 16:22:29 +0200 Subject: [PATCH 22/42] MapLibre compose wrapper library (#877) Heavily inspired from https://github.com/googlemaps/android-maps-compose It doesn't aim to be a full featured library like android-maps-compose, it's been stripped down to only handle our use cases. Related to: https://github.com/vector-im/element-meta/issues/1674 https://github.com/vector-im/element-meta/issues/1682 --- build.gradle.kts | 3 + gradle/libs.versions.toml | 1 + libraries/maplibre-compose/build.gradle.kts | 34 +++ .../libraries/maplibre/compose/CameraMode.kt | 57 ++++ .../compose/CameraMoveStartedReason.kt | 57 ++++ .../maplibre/compose/CameraPositionState.kt | 189 +++++++++++++ .../libraries/maplibre/compose/IconAnchor.kt | 48 ++++ .../libraries/maplibre/compose/MapApplier.kt | 67 +++++ .../maplibre/compose/MapLocationSettings.kt | 31 +++ .../compose/MapSymbolManagerSettings.kt | 31 +++ .../maplibre/compose/MapUiSettings.kt | 41 +++ .../libraries/maplibre/compose/MapUpdater.kt | 154 +++++++++++ .../libraries/maplibre/compose/MapboxMap.kt | 251 ++++++++++++++++++ .../maplibre/compose/MapboxMapComposable.kt | 39 +++ .../libraries/maplibre/compose/Symbol.kt | 124 +++++++++ tools/detekt/detekt.yml | 2 +- tools/detekt/license.template | 10 +- 17 files changed, 1133 insertions(+), 6 deletions(-) create mode 100644 libraries/maplibre-compose/build.gradle.kts create mode 100644 libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMode.kt create mode 100644 libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMoveStartedReason.kt create mode 100644 libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraPositionState.kt create mode 100644 libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/IconAnchor.kt create mode 100644 libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapApplier.kt create mode 100644 libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLocationSettings.kt create mode 100644 libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapSymbolManagerSettings.kt create mode 100644 libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapUiSettings.kt create mode 100644 libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapUpdater.kt create mode 100644 libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapboxMap.kt create mode 100644 libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapboxMapComposable.kt create mode 100644 libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/Symbol.kt diff --git a/build.gradle.kts b/build.gradle.kts index e9adcdee63..02c3ca3043 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -259,6 +259,9 @@ koverMerged { excludes += "io.element.android.libraries.designsystem.swipe.SwipeableActionsState*" excludes += "io.element.android.features.messages.impl.timeline.components.ExpandableState*" excludes += "io.element.android.features.messages.impl.timeline.model.bubble.BubbleState*" + excludes += "io.element.android.libraries.maplibre.compose.CameraPositionState*" + excludes += "io.element.android.libraries.maplibre.compose.SaveableCameraPositionState" + excludes += "io.element.android.libraries.maplibre.compose.SymbolState*" } bound { minValue = 90 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5eeb7a3fa5..d7658a9173 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -158,6 +158,7 @@ vanniktech_emoji = "com.vanniktech:emoji-google:0.16.0" telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" } statemachine = "com.freeletics.flowredux:compose:1.1.0" maplibre = "org.maplibre.gl:android-sdk:10.2.0" +maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:2.0.0" maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:2.0.0" # Analytics diff --git a/libraries/maplibre-compose/build.gradle.kts b/libraries/maplibre-compose/build.gradle.kts new file mode 100644 index 0000000000..e2a9b821ba --- /dev/null +++ b/libraries/maplibre-compose/build.gradle.kts @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.libraries.maplibre.compose" + + kotlinOptions { + freeCompilerArgs += "-Xexplicit-api=strict" + } +} + +dependencies { + api(libs.maplibre) + api(libs.maplibre.ktx) + api(libs.maplibre.annotation) +} diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMode.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMode.kt new file mode 100644 index 0000000000..0c85d3dfb3 --- /dev/null +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMode.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * Copyright 2021 Google LLC + * Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose) + * + * 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.maplibre.compose + +import androidx.compose.runtime.Immutable +import com.mapbox.mapboxsdk.location.modes.CameraMode as InternalCameraMode + +@Immutable +public enum class CameraMode { + NONE, + NONE_COMPASS, + NONE_GPS, + TRACKING, + TRACKING_COMPASS, + TRACKING_GPS, + TRACKING_GPS_NORTH; + + @InternalCameraMode.Mode + internal fun toInternal(): Int = when (this) { + NONE -> InternalCameraMode.NONE + NONE_COMPASS -> InternalCameraMode.NONE_COMPASS + NONE_GPS -> InternalCameraMode.NONE_GPS + TRACKING -> InternalCameraMode.TRACKING + TRACKING_COMPASS -> InternalCameraMode.TRACKING_COMPASS + TRACKING_GPS -> InternalCameraMode.TRACKING_GPS + TRACKING_GPS_NORTH -> InternalCameraMode.TRACKING_GPS_NORTH + } + + internal companion object { + fun fromInternal(@InternalCameraMode.Mode mode: Int): CameraMode = when (mode) { + InternalCameraMode.NONE -> NONE + InternalCameraMode.NONE_COMPASS -> NONE_COMPASS + InternalCameraMode.NONE_GPS -> NONE_GPS + InternalCameraMode.TRACKING -> TRACKING + InternalCameraMode.TRACKING_COMPASS -> TRACKING_COMPASS + InternalCameraMode.TRACKING_GPS -> TRACKING_GPS + InternalCameraMode.TRACKING_GPS_NORTH -> TRACKING_GPS_NORTH + else -> error("Unknown camera mode: $mode") + } + } +} diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMoveStartedReason.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMoveStartedReason.kt new file mode 100644 index 0000000000..10c9d8b69a --- /dev/null +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMoveStartedReason.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * Copyright 2021 Google LLC + * Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose) + * + * 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.maplibre.compose + +import androidx.compose.runtime.Immutable +import com.mapbox.mapboxsdk.maps.MapboxMap.OnCameraMoveStartedListener.REASON_API_ANIMATION +import com.mapbox.mapboxsdk.maps.MapboxMap.OnCameraMoveStartedListener.REASON_API_GESTURE +import com.mapbox.mapboxsdk.maps.MapboxMap.OnCameraMoveStartedListener.REASON_DEVELOPER_ANIMATION + +/** + * Enumerates the different reasons why the map camera started to move. + * + * Based on enum values from https://docs.maptiler.com/maplibre-gl-native-android/com.mapbox.mapboxsdk.maps/#oncameramovestartedlistener. + * + * [NO_MOVEMENT_YET] is used as the initial state before any map movement has been observed. + * + * [UNKNOWN] is used to represent when an unsupported integer value is provided to [fromInt] - this + * may be a new constant value from the Maps SDK that isn't supported by maps-compose yet, in which + * case this library should be updated to include a new enum value for that constant. + */ +@Immutable +public enum class CameraMoveStartedReason(public val value: Int) { + UNKNOWN(-2), + NO_MOVEMENT_YET(-1), + GESTURE(REASON_API_GESTURE), + API_ANIMATION(REASON_API_ANIMATION), + DEVELOPER_ANIMATION(REASON_DEVELOPER_ANIMATION); + + public companion object { + /** + * Converts from the Maps SDK [com.mapbox.mapboxsdk.maps.MapboxMap.OnCameraMoveStartedListener] + * constants to [CameraMoveStartedReason], or returns [UNKNOWN] if there is no such + * [CameraMoveStartedReason] for the given [value]. + * + * See https://docs.maptiler.com/maplibre-gl-native-android/com.mapbox.mapboxsdk.maps/#oncameramovestartedlistener. + */ + public fun fromInt(value: Int): CameraMoveStartedReason { + return values().firstOrNull { it.value == value } ?: return UNKNOWN + } + } +} diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraPositionState.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraPositionState.kt new file mode 100644 index 0000000000..114e6acc02 --- /dev/null +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraPositionState.kt @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * Copyright 2021 Google LLC + * Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose) + * + * 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.maplibre.compose + +import android.location.Location +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import com.mapbox.mapboxsdk.camera.CameraPosition +import com.mapbox.mapboxsdk.camera.CameraUpdateFactory +import com.mapbox.mapboxsdk.maps.MapboxMap +import com.mapbox.mapboxsdk.maps.Projection +import kotlinx.parcelize.Parcelize + +/** + * Create and [rememberSaveable] a [CameraPositionState] using [CameraPositionState.Saver]. + * [init] will be called when the [CameraPositionState] is first created to configure its + * initial state. + */ +@Composable +public inline fun rememberCameraPositionState( + key: String? = null, + crossinline init: CameraPositionState.() -> Unit = {} +): CameraPositionState = rememberSaveable(key = key, saver = CameraPositionState.Saver) { + CameraPositionState().apply(init) +} + +/** + * A state object that can be hoisted to control and observe the map's camera state. + * A [CameraPositionState] may only be used by a single [MapboxMap] composable at a time + * as it reflects instance state for a single view of a map. + * + * @param position the initial camera position + * @param cameraMode the initial camera mode + */ +public class CameraPositionState( + position: CameraPosition = CameraPosition.Builder().build(), + cameraMode: CameraMode = CameraMode.NONE, +) { + /** + * Whether the camera is currently moving or not. This includes any kind of movement: + * panning, zooming, or rotation. + */ + public var isMoving: Boolean by mutableStateOf(false) + internal set + + /** + * The reason for the start of the most recent camera moment, or + * [CameraMoveStartedReason.NO_MOVEMENT_YET] if the camera hasn't moved yet or + * [CameraMoveStartedReason.UNKNOWN] if an unknown constant is received from the Maps SDK. + */ + public var cameraMoveStartedReason: CameraMoveStartedReason by mutableStateOf( + CameraMoveStartedReason.NO_MOVEMENT_YET + ) + internal set + + /** + * Returns the current [Projection] to be used for converting between screen + * coordinates and lat/lng. + */ + public val projection: Projection? + get() = map?.projection + + /** + * Local source of truth for the current camera position. + * While [map] is non-null this reflects the current position of [map] as it changes. + * While [map] is null it reflects the last known map position, or the last value set by + * explicitly setting [position]. + */ + internal var rawPosition by mutableStateOf(position) + + /** + * Current position of the camera on the map. + */ + public var position: CameraPosition + get() = rawPosition + set(value) { + synchronized(lock) { + val map = map + if (map == null) { + rawPosition = value + } else { + map.moveCamera(CameraUpdateFactory.newCameraPosition(value)) + } + } + } + + /** + * Local source of truth for the current camera mode. + * While [map] is non-null this reflects the current camera mode as it changes. + * While [map] is null it reflects the last known camera mode, or the last value set by + * explicitly setting [cameraMode]. + */ + internal var rawCameraMode by mutableStateOf(cameraMode) + + /** + * Current tracking mode of the camera. + */ + public var cameraMode: CameraMode + get() = rawCameraMode + set(value) { + synchronized(lock) { + val map = map + if (map == null) { + rawCameraMode = value + } else { + map.locationComponent.cameraMode = value.toInternal() + } + } + } + + /** + * The user's last available location. + */ + public var location: Location? by mutableStateOf(null) + internal set + + // Used to perform side effects thread-safely. + // Guards all mutable properties that are not `by mutableStateOf`. + private val lock = Unit + + // The map currently associated with this CameraPositionState. + // Guarded by `lock`. + private var map: MapboxMap? by mutableStateOf(null) + + // The current map is set and cleared by side effect. + // There can be only one associated at a time. + internal fun setMap(map: MapboxMap?) { + synchronized(lock) { + if (this.map == null && map == null) return + if (this.map != null && map != null) { + error("CameraPositionState may only be associated with one MapboxMap at a time") + } + this.map = map + if (map == null) { + isMoving = false + } else { + map.moveCamera(CameraUpdateFactory.newCameraPosition(position)) + map.locationComponent.cameraMode = cameraMode.toInternal() + } + } + } + + public companion object { + /** + * The default saver implementation for [CameraPositionState]. + */ + public val Saver: Saver = Saver( + save = { SaveableCameraPositionState(it.position, it.cameraMode.toInternal()) }, + restore = { CameraPositionState(it.position, CameraMode.fromInternal(it.cameraMode)) } + ) + } +} + +/** Provides the [CameraPositionState] used by the map. */ +internal val LocalCameraPositionState = staticCompositionLocalOf { CameraPositionState() } + +/** The current [CameraPositionState] used by the map. */ +public val currentCameraPositionState: CameraPositionState + @[MapboxMapComposable ReadOnlyComposable Composable] + get() = LocalCameraPositionState.current + +@Parcelize +public data class SaveableCameraPositionState( + val position: CameraPosition, + val cameraMode: Int +) : Parcelable diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/IconAnchor.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/IconAnchor.kt new file mode 100644 index 0000000000..25f6f38c66 --- /dev/null +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/IconAnchor.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * Copyright 2021 Google LLC + * Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose) + * + * 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.maplibre.compose + +import androidx.compose.runtime.Immutable +import com.mapbox.mapboxsdk.style.layers.Property + +@Immutable +public enum class IconAnchor { + CENTER, + LEFT, + RIGHT, + TOP, + BOTTOM, + TOP_LEFT, + TOP_RIGHT, + BOTTOM_LEFT, + BOTTOM_RIGHT; + + @Property.ICON_ANCHOR + internal fun toInternal(): String = when (this) { + CENTER -> Property.ICON_ANCHOR_CENTER + LEFT -> Property.ICON_ANCHOR_LEFT + RIGHT -> Property.ICON_ANCHOR_RIGHT + TOP -> Property.ICON_ANCHOR_TOP + BOTTOM -> Property.ICON_ANCHOR_BOTTOM + TOP_LEFT -> Property.ICON_ANCHOR_TOP_LEFT + TOP_RIGHT -> Property.ICON_ANCHOR_TOP_RIGHT + BOTTOM_LEFT -> Property.ICON_ANCHOR_BOTTOM_LEFT + BOTTOM_RIGHT -> Property.ICON_ANCHOR_BOTTOM_RIGHT + } +} diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapApplier.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapApplier.kt new file mode 100644 index 0000000000..b6cfff034a --- /dev/null +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapApplier.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * Copyright 2021 Google LLC + * Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose) + * + * 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.maplibre.compose + +import androidx.compose.runtime.AbstractApplier +import com.mapbox.mapboxsdk.maps.MapboxMap +import com.mapbox.mapboxsdk.maps.Style +import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager + +internal interface MapNode { + fun onAttached() {} + fun onRemoved() {} + fun onCleared() {} +} + +private object MapNodeRoot : MapNode + +internal class MapApplier( + val map: MapboxMap, + val style: Style, + val symbolManager: SymbolManager, +) : AbstractApplier(MapNodeRoot) { + + private val decorations = mutableListOf() + + override fun onClear() { + symbolManager.deleteAll() + decorations.forEach { it.onCleared() } + decorations.clear() + } + + override fun insertBottomUp(index: Int, instance: MapNode) { + decorations.add(index, instance) + instance.onAttached() + } + + override fun insertTopDown(index: Int, instance: MapNode) { + // insertBottomUp is preferred + } + + override fun move(from: Int, to: Int, count: Int) { + decorations.move(from, to, count) + } + + override fun remove(index: Int, count: Int) { + repeat(count) { + decorations[index + it].onRemoved() + } + decorations.remove(index, count) + } +} diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLocationSettings.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLocationSettings.kt new file mode 100644 index 0000000000..4b7b7005f2 --- /dev/null +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLocationSettings.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * Copyright 2021 Google LLC + * Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose) + * + * 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.maplibre.compose + +internal val DefaultMapLocationSettings = MapLocationSettings() + +/** + * Data class for UI-related settings on the map. + * + * Note: Should not be a data class if in need of maintaining binary compatibility + * on future changes. See: https://jakewharton.com/public-api-challenges-in-kotlin/ + */ +public data class MapLocationSettings( + public val locationEnabled: Boolean = false, +) diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapSymbolManagerSettings.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapSymbolManagerSettings.kt new file mode 100644 index 0000000000..4bd2ff9e1e --- /dev/null +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapSymbolManagerSettings.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * Copyright 2021 Google LLC + * Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose) + * + * 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.maplibre.compose + +internal val DefaultMapSymbolManagerSettings = MapSymbolManagerSettings() + +/** + * Data class for UI-related settings on the map. + * + * Note: Should not be a data class if in need of maintaining binary compatibility + * on future changes. See: https://jakewharton.com/public-api-challenges-in-kotlin/ + */ +public data class MapSymbolManagerSettings( + public val iconAllowOverlap: Boolean = false, +) diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapUiSettings.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapUiSettings.kt new file mode 100644 index 0000000000..a18c05a8f9 --- /dev/null +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapUiSettings.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * Copyright 2021 Google LLC + * Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose) + * + * 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.maplibre.compose + +import android.view.Gravity +import androidx.compose.ui.graphics.Color + +internal val DefaultMapUiSettings = MapUiSettings() + +/** + * Data class for UI-related settings on the map. + * + * Note: Should not be a data class if in need of maintaining binary compatibility + * on future changes. See: https://jakewharton.com/public-api-challenges-in-kotlin/ + */ +public data class MapUiSettings( + public val compassEnabled: Boolean = true, + public val rotationGesturesEnabled: Boolean = true, + public val scrollGesturesEnabled: Boolean = true, + public val tiltGesturesEnabled: Boolean = true, + public val zoomGesturesEnabled: Boolean = true, + public val logoGravity: Int = Gravity.BOTTOM, + public val attributionGravity: Int = Gravity.BOTTOM, + public val attributionTintColor: Color = Color.Unspecified, +) diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapUpdater.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapUpdater.kt new file mode 100644 index 0000000000..d7d5f9ca11 --- /dev/null +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapUpdater.kt @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * Copyright 2021 Google LLC + * Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose) + * + * 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. + */ +@file:Suppress("MatchingDeclarationName") +package io.element.android.libraries.maplibre.compose + +import android.annotation.SuppressLint +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ComposeNode +import androidx.compose.runtime.currentComposer +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import com.mapbox.mapboxsdk.location.LocationComponentActivationOptions +import com.mapbox.mapboxsdk.location.LocationComponentOptions +import com.mapbox.mapboxsdk.location.OnCameraTrackingChangedListener +import com.mapbox.mapboxsdk.location.engine.LocationEngineRequest +import com.mapbox.mapboxsdk.maps.MapboxMap +import com.mapbox.mapboxsdk.maps.Style + +private const val LOCATION_REQUEST_INTERVAL = 750L + +internal class MapPropertiesNode( + val map: MapboxMap, + style: Style, + context: Context, + cameraPositionState: CameraPositionState, +) : MapNode { + + init { + map.locationComponent.activateLocationComponent( + LocationComponentActivationOptions.Builder(context, style) + .locationComponentOptions( + LocationComponentOptions.builder(context) + .pulseEnabled(true) + .build() + ) + .locationEngineRequest( + LocationEngineRequest.Builder(LOCATION_REQUEST_INTERVAL) + .setPriority(LocationEngineRequest.PRIORITY_HIGH_ACCURACY) + .setFastestInterval(LOCATION_REQUEST_INTERVAL) + .build() + ) + .build() + ) + cameraPositionState.setMap(map) + } + + var cameraPositionState = cameraPositionState + set(value) { + if (value == field) return + field.setMap(null) + field = value + value.setMap(map) + } + + override fun onAttached() { + map.addOnCameraIdleListener { + cameraPositionState.isMoving = false + // addOnCameraIdleListener is only invoked when the camera position + // is changed via .animate(). To handle updating state when .move() + // is used, it's necessary to set the camera's position here as well + cameraPositionState.rawPosition = map.cameraPosition + // Updating user location on every camera move due to lack of a better location updates API. + cameraPositionState.location = map.locationComponent.lastKnownLocation + } + map.addOnCameraMoveCancelListener { + cameraPositionState.isMoving = false + } + map.addOnCameraMoveStartedListener { + cameraPositionState.cameraMoveStartedReason = CameraMoveStartedReason.fromInt(it) + cameraPositionState.isMoving = true + } + map.addOnCameraMoveListener { + cameraPositionState.rawPosition = map.cameraPosition + // Updating user location on every camera move due to lack of a better location updates API. + cameraPositionState.location = map.locationComponent.lastKnownLocation + } + map.locationComponent.addOnCameraTrackingChangedListener(object : OnCameraTrackingChangedListener { + override fun onCameraTrackingDismissed() {} + + override fun onCameraTrackingChanged(currentMode: Int) { + cameraPositionState.rawCameraMode = CameraMode.fromInternal(currentMode) + } + }) + } + + override fun onRemoved() { + cameraPositionState.setMap(null) + } + + override fun onCleared() { + cameraPositionState.setMap(null) + } +} + +/** + * Used to keep the primary map properties up to date. This should never leave the map composition. + */ +@SuppressLint("MissingPermission") +@Suppress("NOTHING_TO_INLINE") +@Composable +internal inline fun MapUpdater( + cameraPositionState: CameraPositionState, + mapLocationSettings: MapLocationSettings, + mapUiSettings: MapUiSettings, + mapSymbolManagerSettings: MapSymbolManagerSettings, +) { + val mapApplier = currentComposer.applier as MapApplier + val map = mapApplier.map + val style = mapApplier.style + val symbolManager = mapApplier.symbolManager + val context = LocalContext.current + ComposeNode( + factory = { + MapPropertiesNode( + map = map, + style = style, + context = context, + cameraPositionState = cameraPositionState, + ) + }, + update = { + set(mapLocationSettings.locationEnabled) { map.locationComponent.isLocationComponentEnabled = it } + + set(mapUiSettings.compassEnabled) { map.uiSettings.isCompassEnabled = it } + set(mapUiSettings.rotationGesturesEnabled) { map.uiSettings.isRotateGesturesEnabled = it } + set(mapUiSettings.scrollGesturesEnabled) { map.uiSettings.isScrollGesturesEnabled = it } + set(mapUiSettings.tiltGesturesEnabled) { map.uiSettings.isTiltGesturesEnabled = it } + set(mapUiSettings.zoomGesturesEnabled) { map.uiSettings.isZoomGesturesEnabled = it } + set(mapUiSettings.logoGravity) { map.uiSettings.logoGravity = it } + set(mapUiSettings.attributionGravity) { map.uiSettings.attributionGravity = it } + set(mapUiSettings.attributionTintColor) { map.uiSettings.setAttributionTintColor(it.toArgb()) } + + set(mapSymbolManagerSettings.iconAllowOverlap) { symbolManager.iconAllowOverlap = it } + + update(cameraPositionState) { this.cameraPositionState = it } + } + ) +} diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapboxMap.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapboxMap.kt new file mode 100644 index 0000000000..3c3cf3e44f --- /dev/null +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapboxMap.kt @@ -0,0 +1,251 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * Copyright 2021 Google LLC + * Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose) + * + * 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.maplibre.compose + +import android.content.ComponentCallbacks +import android.content.Context +import android.content.res.Configuration +import android.os.Bundle +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Composition +import androidx.compose.runtime.CompositionContext +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCompositionContext +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import com.mapbox.mapboxsdk.Mapbox +import com.mapbox.mapboxsdk.maps.MapView +import com.mapbox.mapboxsdk.maps.MapboxMap +import com.mapbox.mapboxsdk.maps.Style +import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.coroutines.awaitCancellation +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +/** + * A compose container for a MapLibre [MapView]. + * + * Heavily inspired by https://github.com/googlemaps/android-maps-compose + * + * @param styleUri a URI where to asynchronously fetch a style for the map + * @param modifier Modifier to be applied to the MapboxMap + * @param images images added to the map's style to be later used with [Symbol] + * @param cameraPositionState the [CameraPositionState] to be used to control or observe the map's + * camera state + * @param uiSettings the [MapUiSettings] to be used for UI-specific settings on the map + * @param symbolManagerSettings the [MapSymbolManagerSettings] to be used for symbol manager settings + * @param locationSettings the [MapLocationSettings] to be used for location settings + * @param content the content of the map + */ +@Composable +public fun MapboxMap( + styleUri: String, + modifier: Modifier = Modifier, + images: ImmutableMap = persistentMapOf(), + cameraPositionState: CameraPositionState = rememberCameraPositionState(), + uiSettings: MapUiSettings = DefaultMapUiSettings, + symbolManagerSettings: MapSymbolManagerSettings = DefaultMapSymbolManagerSettings, + locationSettings: MapLocationSettings = DefaultMapLocationSettings, + content: (@Composable @MapboxMapComposable () -> Unit)? = null, +) { + // When in preview, early return a Box with the received modifier preserving layout + if (LocalInspectionMode.current) { + @Suppress("ModifierReused") // False positive, the modifier is not reused due to the early return. + Box( + modifier = modifier.background(Color.DarkGray) + ) { + Text("[Map]", modifier = Modifier.align(Alignment.Center)) + } + return + } + + val context = LocalContext.current + val mapView = remember { + Mapbox.getInstance(context) + MapView(context) + } + + @Suppress("ModifierReused") + AndroidView(modifier = modifier, factory = { mapView }) + MapLifecycle(mapView) + + // rememberUpdatedState and friends are used here to make these values observable to + // the subcomposition without providing a new content function each recomposition + val currentCameraPositionState by rememberUpdatedState(cameraPositionState) + val currentUiSettings by rememberUpdatedState(uiSettings) + val currentMapLocationSettings by rememberUpdatedState(locationSettings) + val currentSymbolManagerSettings by rememberUpdatedState(symbolManagerSettings) + + val parentComposition = rememberCompositionContext() + val currentContent by rememberUpdatedState(content) + + LaunchedEffect(styleUri, images) { + disposingComposition { + parentComposition.newComposition( + context = context, + mapView = mapView, + styleUri = styleUri, + images = images, + ) { + MapUpdater( + cameraPositionState = currentCameraPositionState, + mapUiSettings = currentUiSettings, + mapLocationSettings = currentMapLocationSettings, + mapSymbolManagerSettings = currentSymbolManagerSettings, + ) + CompositionLocalProvider( + LocalCameraPositionState provides cameraPositionState, + ) { + currentContent?.invoke() + } + } + } + } +} + +private suspend inline fun disposingComposition(factory: () -> Composition) { + val composition = factory() + try { + awaitCancellation() + } finally { + composition.dispose() + } +} + +private suspend inline fun CompositionContext.newComposition( + context: Context, + mapView: MapView, + styleUri: String, + images: ImmutableMap, + noinline content: @Composable () -> Unit +): Composition { + val map = mapView.awaitMap() + val style = map.awaitStyle(context, styleUri, images) + val symbolManager = SymbolManager(mapView, map, style) + return Composition( + MapApplier(map, style, symbolManager), this + ).apply { + setContent(content) + } +} + +private suspend inline fun MapView.awaitMap(): MapboxMap = suspendCoroutine { continuation -> + getMapAsync { map -> + continuation.resume(map) + } +} + +private suspend inline fun MapboxMap.awaitStyle( + context: Context, + styleUri: String, + images: ImmutableMap, +): Style = suspendCoroutine { continuation -> + setStyle( + Style.Builder().apply { + fromUri(styleUri) + images.forEach { (id, drawableRes) -> + withImage(id, checkNotNull(context.getDrawable(drawableRes)) { + "Drawable resource $drawableRes with id $id not found" + }) + } + } + ) { style -> + continuation.resume(style) + } +} + +/** + * Registers lifecycle observers to the local [MapView]. + */ +@Composable +private fun MapLifecycle(mapView: MapView) { + val context = LocalContext.current + val lifecycle = LocalLifecycleOwner.current.lifecycle + val previousState = remember { mutableStateOf(Lifecycle.Event.ON_CREATE) } + DisposableEffect(context, lifecycle, mapView) { + val mapLifecycleObserver = mapView.lifecycleObserver(previousState) + val callbacks = mapView.componentCallbacks() + + lifecycle.addObserver(mapLifecycleObserver) + context.registerComponentCallbacks(callbacks) + + onDispose { + lifecycle.removeObserver(mapLifecycleObserver) + context.unregisterComponentCallbacks(callbacks) + } + } + DisposableEffect(mapView) { + onDispose { + mapView.onDestroy() + mapView.removeAllViews() + } + } +} + +private fun MapView.lifecycleObserver(previousState: MutableState): LifecycleEventObserver = + LifecycleEventObserver { _, event -> + event.targetState + when (event) { + Lifecycle.Event.ON_CREATE -> { + // Skip calling mapView.onCreate if the lifecycle did not go through onDestroy - in + // this case the MapboxMap composable also doesn't leave the composition. So, + // recreating the map does not restore state properly which must be avoided. + if (previousState.value != Lifecycle.Event.ON_STOP) { + this.onCreate(Bundle()) + } + } + Lifecycle.Event.ON_START -> this.onStart() + Lifecycle.Event.ON_RESUME -> this.onResume() + Lifecycle.Event.ON_PAUSE -> this.onPause() + Lifecycle.Event.ON_STOP -> this.onStop() + Lifecycle.Event.ON_DESTROY -> { + //handled in onDispose + } + else -> throw IllegalStateException() + } + previousState.value = event + } + +private fun MapView.componentCallbacks(): ComponentCallbacks = + object : ComponentCallbacks { + override fun onConfigurationChanged(config: Configuration) {} + + override fun onLowMemory() { + this@componentCallbacks.onLowMemory() + } + } diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapboxMapComposable.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapboxMapComposable.kt new file mode 100644 index 0000000000..15876b0033 --- /dev/null +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapboxMapComposable.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * Copyright 2021 Google LLC + * Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose) + * + * 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.maplibre.compose + +import androidx.compose.runtime.ComposableTargetMarker + +/** + * An annotation that can be used to mark a composable function as being expected to be use in a + * composable function that is also marked or inferred to be marked as a [MapboxMapComposable]. + * + * This will produce build warnings when [MapboxMapComposable] composable functions are used outside + * of a [MapboxMapComposable] content lambda, and vice versa. + */ +@Retention(AnnotationRetention.BINARY) +@ComposableTargetMarker(description = "MapLibre Map Composable") +@Target( + AnnotationTarget.FILE, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY_GETTER, + AnnotationTarget.TYPE, + AnnotationTarget.TYPE_PARAMETER, +) +public annotation class MapboxMapComposable diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/Symbol.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/Symbol.kt new file mode 100644 index 0000000000..36e8cdc34e --- /dev/null +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/Symbol.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * Copyright 2021 Google LLC + * Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose) + * + * 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.maplibre.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ComposeNode +import androidx.compose.runtime.currentComposer +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import com.mapbox.mapboxsdk.geometry.LatLng +import com.mapbox.mapboxsdk.plugins.annotation.Symbol +import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager +import com.mapbox.mapboxsdk.plugins.annotation.SymbolOptions + +internal class SymbolNode( + val symbolManager: SymbolManager, + val symbol: Symbol, +) : MapNode { + override fun onRemoved() { + symbolManager.delete(symbol) + } + + override fun onCleared() { + symbolManager.delete(symbol) + } +} + +/** + * A state object that can be hoisted to control and observe the symbol state. + * + * @param position the initial symbol position + */ +public class SymbolState( + position: LatLng = LatLng(0.0, 0.0) +) { + /** + * Current position of the symbol. + */ + public var position: LatLng by mutableStateOf(position) + + public companion object { + /** + * The default saver implementation for [SymbolState]. + */ + public val Saver: Saver = Saver( + save = { it.position }, + restore = { SymbolState(it) } + ) + } +} + +@Composable +public fun rememberSymbolState( + key: String? = null, + position: LatLng = LatLng(0.0, 0.0) +): SymbolState = rememberSaveable(key = key, saver = SymbolState.Saver) { + SymbolState(position) +} + +/** + * A composable for a symbol on the map. + * + * @param iconId an id of an image from the current [Style] + * @param state the [SymbolState] to be used to control or observe the symbol + * state such as its position and info window + * @param iconAnchor the anchor for the symbol image + */ +@Composable +@MapboxMapComposable +public fun Symbol( + iconId: String, + state: SymbolState = rememberSymbolState(), + iconAnchor: IconAnchor? = null, +) { + val mapApplier = currentComposer.applier as MapApplier + val symbolManager = mapApplier.symbolManager + ComposeNode( + factory = { + SymbolNode( + symbolManager = symbolManager, + symbol = symbolManager.create( + SymbolOptions().apply { + withLatLng(state.position) + withIconImage(iconId) + iconAnchor?.let { withIconAnchor(it.toInternal()) } + } + ), + ) + }, + update = { + update(state.position) { + this.symbol.latLng = it + symbolManager.update(this.symbol) + } + update(iconId) { + this.symbol.iconImage = it + symbolManager.update(this.symbol) + } + update(iconAnchor) { + this.symbol.iconAnchor = it?.toInternal() + symbolManager.update(this.symbol) + } + } + ) +} diff --git a/tools/detekt/detekt.yml b/tools/detekt/detekt.yml index f18f49a358..a3bad54ab3 100644 --- a/tools/detekt/detekt.yml +++ b/tools/detekt/detekt.yml @@ -113,7 +113,7 @@ Compose: CompositionLocalAllowlist: active: true # You can optionally define a list of CompositionLocals that are allowed here - allowedCompositionLocals: LocalCompoundColors, LocalSnackbarDispatcher + allowedCompositionLocals: LocalCompoundColors, LocalSnackbarDispatcher, LocalCameraPositionState CompositionLocalNaming: active: true ContentEmitterReturningValues: diff --git a/tools/detekt/license.template b/tools/detekt/license.template index 63b899da9b..08cadc82f9 100644 --- a/tools/detekt/license.template +++ b/tools/detekt/license.template @@ -1,15 +1,15 @@ -/\* -(.*\n)* \* Copyright \(c\) 20\d\d New Vector Ltd(.*\n)* - \* +\/\* +(?:.*\n)* \* Copyright \(c\) 20\d\d New Vector Ltd +(?:.*\n)* \* \* 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(s)?://www\.apache\.org/licenses/LICENSE-2\.0 + \* http(?:s)?:\/\/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\. - \*/ + \*\/ From f292c433ec66ad1d9fce87188913a91545ba1b75 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 17 Jul 2023 16:18:54 +0200 Subject: [PATCH 23/42] Remove StableCharSequence, it was useful when we were using the Epoxy library. --- .../messages/impl/MessagesStateProvider.kt | 3 +- .../messagecomposer/MessageComposerEvents.kt | 2 +- .../MessageComposerPresenter.kt | 16 +++---- .../messagecomposer/MessageComposerState.kt | 5 +-- .../MessageComposerStateProvider.kt | 3 +- .../messagecomposer/MessageComposerView.kt | 4 +- .../MessageComposerPresenterTest.kt | 43 +++++++++---------- .../libraries/core/data/StableCharSequence.kt | 31 ------------- .../textcomposer/MessageComposerMode.kt | 8 ++-- .../libraries/textcomposer/TextComposer.kt | 2 +- 10 files changed, 40 insertions(+), 77 deletions(-) delete mode 100644 libraries/core/src/main/kotlin/io/element/android/libraries/core/data/StableCharSequence.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt index 69cb0fa493..d0ddcf68f4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -25,7 +25,6 @@ import io.element.android.features.messages.impl.timeline.components.customreact import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.libraries.architecture.Async -import io.element.android.libraries.core.data.StableCharSequence import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.matrix.api.core.RoomId @@ -48,7 +47,7 @@ fun aMessagesState() = MessagesState( roomAvatar = AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom), userHasPermissionToSendMessage = true, composerState = aMessageComposerState().copy( - text = StableCharSequence("Hello"), + text = "Hello", isFullScreen = false, mode = MessageComposerMode.Normal("Hello"), ), diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt index 82fb0982f4..46e57e92de 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt @@ -26,7 +26,7 @@ sealed interface MessageComposerEvents { data class SendMessage(val message: String) : MessageComposerEvents object CloseSpecialMode : MessageComposerEvents data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvents - data class UpdateText(val text: CharSequence) : MessageComposerEvents + data class UpdateText(val text: String) : MessageComposerEvents object AddAttachment : MessageComposerEvents object DismissAttachmentMenu : MessageComposerEvents sealed interface PickAttachmentSource : MessageComposerEvents { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index f7c80b4320..4d749b465e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -34,8 +34,6 @@ import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError import io.element.android.features.messages.impl.media.local.LocalMediaFactory import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.core.data.StableCharSequence -import io.element.android.libraries.core.data.toStableCharSequence import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.SnackbarMessage import io.element.android.libraries.di.RoomScope @@ -94,15 +92,15 @@ class MessageComposerPresenter @Inject constructor( val hasFocus = remember { mutableStateOf(false) } - val text: MutableState = remember { - mutableStateOf(StableCharSequence("")) + val text: MutableState = remember { + mutableStateOf("") } var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) } LaunchedEffect(messageComposerContext.composerMode) { when (val modeValue = messageComposerContext.composerMode) { - is MessageComposerMode.Edit -> text.value = modeValue.defaultContent.toStableCharSequence() + is MessageComposerMode.Edit -> text.value = modeValue.defaultContent else -> Unit } } @@ -120,9 +118,9 @@ class MessageComposerPresenter @Inject constructor( is MessageComposerEvents.FocusChanged -> hasFocus.value = event.hasFocus - is MessageComposerEvents.UpdateText -> text.value = event.text.toStableCharSequence() + is MessageComposerEvents.UpdateText -> text.value = event.text MessageComposerEvents.CloseSpecialMode -> { - text.value = "".toStableCharSequence() + text.value = "" messageComposerContext.composerMode = MessageComposerMode.Normal("") } @@ -189,11 +187,11 @@ class MessageComposerPresenter @Inject constructor( private fun CoroutineScope.sendMessage( text: String, updateComposerMode: (newComposerMode: MessageComposerMode) -> Unit, - textState: MutableState + textState: MutableState ) = launch { val capturedMode = messageComposerContext.composerMode // Reset composer right away - textState.value = "".toStableCharSequence() + textState.value = "" updateComposerMode(MessageComposerMode.Normal("")) when (capturedMode) { is MessageComposerMode.Normal -> room.sendMessage(text) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt index 2dc6042fb5..28ec14ffeb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt @@ -18,13 +18,12 @@ package io.element.android.features.messages.impl.messagecomposer import androidx.compose.runtime.Immutable import io.element.android.features.messages.impl.attachments.Attachment -import io.element.android.libraries.core.data.StableCharSequence import io.element.android.libraries.textcomposer.MessageComposerMode import kotlinx.collections.immutable.ImmutableList @Immutable data class MessageComposerState( - val text: StableCharSequence?, + val text: String?, val isFullScreen: Boolean, val hasFocus: Boolean, val mode: MessageComposerMode, @@ -32,7 +31,7 @@ data class MessageComposerState( val attachmentsState: AttachmentsState, val eventSink: (MessageComposerEvents) -> Unit ) { - val isSendButtonVisible: Boolean = text?.charSequence.isNullOrEmpty().not() + val isSendButtonVisible: Boolean = text.isNullOrEmpty().not() } @Immutable diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt index 0504d3625a..1934154824 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt @@ -17,7 +17,6 @@ package io.element.android.features.messages.impl.messagecomposer import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.libraries.core.data.StableCharSequence import io.element.android.libraries.textcomposer.MessageComposerMode open class MessageComposerStateProvider : PreviewParameterProvider { @@ -28,7 +27,7 @@ open class MessageComposerStateProvider : PreviewParameterProvider Unit = {}, onResetComposerMode: () -> Unit = {}, - onComposerTextChange: (CharSequence) -> Unit = {}, + onComposerTextChange: (String) -> Unit = {}, onAddAttachment: () -> Unit = {}, onFocusChanged: (Boolean) -> Unit = {}, ) { From e61af2eb7d567fe12b455f77956f35100f0461bb Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Mon, 17 Jul 2023 17:02:06 +0200 Subject: [PATCH 24/42] Fix: make sure we ignore notifications for open rooms (#867) * Make sure we ignore notifications for open rooms - Listen to process lifecycle changes in `AppForegroundStateService`. Use initializers to reliable create it. - Merge `AppNavigationState` with `AppForegroundState`. Renamed the previous `AppNavigationState` to `NavigationState`, created a new `AppNavigationState` which contains both the navigation state and the foreground state. --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 12 ++ .../android/appnav/RoomFlowNodeTest.kt | 4 +- libraries/push/impl/build.gradle.kts | 1 - .../DefaultNotificationDrawerManager.kt | 38 ++--- .../notifications/NotifiableEventProcessor.kt | 9 +- .../model/NotifiableMessageEvent.kt | 17 +- .../NotifiableEventProcessorTest.kt | 54 +++++-- services/appnavstate/api/build.gradle.kts | 3 + .../api/AppForegroundStateService.kt | 34 ++++ .../appnavstate/api/AppNavigationState.kt | 43 +---- .../api/AppNavigationStateExtension.kt | 62 -------- .../api/AppNavigationStateService.kt | 6 +- .../appnavstate/api/NavigationState.kt | 58 +++++++ .../api/NavigationStateExtension.kt | 62 ++++++++ services/appnavstate/impl/build.gradle.kts | 2 + .../impl/DefaultAppForegroundStateService.kt | 40 +++++ .../impl/DefaultAppNavigationStateService.kt | 149 ++++++++++-------- .../appnavstate/impl/di/AppNavStateModule.kt | 39 +++++ .../AppForegroundStateServiceInitializer.kt | 33 ++++ ...t => DefaultNavigationStateServiceTest.kt} | 30 ++-- .../impl/FakeAppForegroundStateService.kt | 37 +++++ services/appnavstate/test/build.gradle.kts | 1 + .../appnavstate/test/AppNavStateFixture.kt | 16 +- ...ce.kt => FakeAppNavigationStateService.kt} | 16 +- .../tests/testutils/RunCancellableTest.kt | 31 ++++ 26 files changed, 552 insertions(+), 246 deletions(-) create mode 100644 services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppForegroundStateService.kt delete mode 100644 services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationStateExtension.kt create mode 100644 services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/NavigationState.kt create mode 100644 services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/NavigationStateExtension.kt create mode 100644 services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/DefaultAppForegroundStateService.kt create mode 100644 services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/di/AppNavStateModule.kt create mode 100644 services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/initializer/AppForegroundStateServiceInitializer.kt rename services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/{DefaultAppNavigationStateServiceTest.kt => DefaultNavigationStateServiceTest.kt} (70%) create mode 100644 services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/FakeAppForegroundStateService.kt rename services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/{NoopAppNavigationStateService.kt => FakeAppNavigationStateService.kt} (78%) create mode 100644 tests/testutils/src/main/kotlin/io/element/android/tests/testutils/RunCancellableTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dd198cd5aa..32bfd40629 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -209,6 +209,7 @@ dependencies { implementation(libs.androidx.core) implementation(libs.androidx.corektx) implementation(libs.androidx.lifecycle.runtime) + implementation(libs.androidx.lifecycle.process) implementation(libs.androidx.activity.compose) implementation(libs.androidx.startup) implementation(libs.androidx.preference) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c77fba93e1..2917c5199b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -32,6 +32,18 @@ android:theme="@style/Theme.ElementX" tools:targetApi="33"> + + + + + + {} - is AppNavigationState.Session -> {} - is AppNavigationState.Space -> {} - is AppNavigationState.Room -> { + private fun onAppNavigationStateChange(navigationState: NavigationState) { + when (navigationState) { + NavigationState.Root -> {} + is NavigationState.Session -> {} + is NavigationState.Space -> {} + is NavigationState.Room -> { // Cleanup notification for current room - clearMessagesForRoom(appNavigationState.parentSpace.parentSession.sessionId, appNavigationState.roomId) + clearMessagesForRoom(navigationState.parentSpace.parentSession.sessionId, navigationState.roomId) } - is AppNavigationState.Thread -> { + is NavigationState.Thread -> { onEnteringThread( - appNavigationState.parentRoom.parentSpace.parentSession.sessionId, - appNavigationState.parentRoom.roomId, - appNavigationState.threadId + navigationState.parentRoom.parentSpace.parentSession.sessionId, + navigationState.parentRoom.roomId, + navigationState.threadId ) } } @@ -225,7 +221,7 @@ class DefaultNotificationDrawerManager @Inject constructor( private suspend fun refreshNotificationDrawerBg() { Timber.v("refreshNotificationDrawerBg()") val eventsToRender = notificationState.updateQueuedEvents(this) { queuedEvents, renderedEvents -> - notifiableEventProcessor.process(queuedEvents.rawEvents(), currentAppNavigationState, renderedEvents).also { + notifiableEventProcessor.process(queuedEvents.rawEvents(), renderedEvents).also { queuedEvents.clearAndAdd(it.onlyKeptEvents()) } } @@ -275,8 +271,4 @@ class DefaultNotificationDrawerManager @Inject constructor( notificationRenderer.render(currentUser, useCompleteNotificationFormat, notifiableEvents) } } - - fun shouldIgnoreMessageEventInRoom(resolvedEvent: NotifiableMessageEvent): Boolean { - return resolvedEvent.shouldIgnoreEventInRoom(currentAppNavigationState) - } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessor.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessor.kt index 4202ef78d4..50f1b88783 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessor.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessor.kt @@ -23,7 +23,7 @@ import io.element.android.libraries.push.impl.notifications.model.NotifiableEven import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreEventInRoom -import io.element.android.services.appnavstate.api.AppNavigationState +import io.element.android.services.appnavstate.api.AppNavigationStateService import timber.log.Timber import javax.inject.Inject @@ -31,18 +31,19 @@ private typealias ProcessedEvents = List> class NotifiableEventProcessor @Inject constructor( private val outdatedDetector: OutdatedEventDetector, + private val appNavigationStateService: AppNavigationStateService, ) { fun process( queuedEvents: List, - appNavigationState: AppNavigationState?, renderedEvents: ProcessedEvents, ): ProcessedEvents { + val appState = appNavigationStateService.appNavigationState.value val processedEvents = queuedEvents.map { val type = when (it) { is InviteNotifiableEvent -> ProcessedEvent.Type.KEEP is NotifiableMessageEvent -> when { - it.shouldIgnoreEventInRoom(appNavigationState) -> { + it.shouldIgnoreEventInRoom(appState) -> { ProcessedEvent.Type.REMOVE .also { Timber.d("notification message removed due to currently viewing the same room or thread") } } @@ -55,7 +56,7 @@ class NotifiableEventProcessor @Inject constructor( else -> ProcessedEvent.Type.KEEP } is FallbackNotifiableEvent -> when { - it.shouldIgnoreEventInRoom(appNavigationState) -> { + it.shouldIgnoreEventInRoom(appState) -> { ProcessedEvent.Type.REMOVE .also { Timber.d("notification fallback removed due to currently viewing the same room or thread") } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt index 7730066d31..57a3eb45aa 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt @@ -16,8 +16,6 @@ package io.element.android.libraries.push.impl.notifications.model import android.net.Uri -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.ProcessLifecycleOwner import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId @@ -69,18 +67,13 @@ data class NotifiableMessageEvent( /** * Used to check if a notification should be ignored based on the current app and navigation state. */ -fun NotifiableEvent.shouldIgnoreEventInRoom( - appNavigationState: AppNavigationState? -): Boolean { - val currentSessionId = appNavigationState?.currentSessionId() ?: return false - return when (val currentRoomId = appNavigationState.currentRoomId()) { +fun NotifiableEvent.shouldIgnoreEventInRoom(appNavigationState: AppNavigationState): Boolean { + val currentSessionId = appNavigationState.navigationState.currentSessionId() ?: return false + return when (val currentRoomId = appNavigationState.navigationState.currentRoomId()) { null -> false - else -> isAppInForeground + else -> appNavigationState.isInForeground && sessionId == currentSessionId && roomId == currentRoomId - && (this as? NotifiableMessageEvent)?.threadId == appNavigationState.currentThreadId() + && (this as? NotifiableMessageEvent)?.threadId == appNavigationState.navigationState.currentThreadId() } } - -private val isAppInForeground: Boolean - get() = ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessorTest.kt index a1398ef429..28b001ca28 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessorTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessorTest.kt @@ -30,17 +30,20 @@ import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiable import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent -import io.element.android.services.appnavstate.test.anAppNavigationState +import io.element.android.services.appnavstate.api.NavigationState +import io.element.android.services.appnavstate.api.AppNavigationState +import io.element.android.services.appnavstate.test.FakeAppNavigationStateService +import io.element.android.services.appnavstate.test.aNavigationState +import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Test -private val NOT_VIEWING_A_ROOM = anAppNavigationState() -private val VIEWING_A_ROOM = anAppNavigationState(A_SESSION_ID, A_SPACE_ID, A_ROOM_ID) -private val VIEWING_A_THREAD = anAppNavigationState(A_SESSION_ID, A_SPACE_ID, A_ROOM_ID, A_THREAD_ID) +private val NOT_VIEWING_A_ROOM = aNavigationState() +private val VIEWING_A_ROOM = aNavigationState(A_SESSION_ID, A_SPACE_ID, A_ROOM_ID) +private val VIEWING_A_THREAD = aNavigationState(A_SESSION_ID, A_SPACE_ID, A_ROOM_ID, A_THREAD_ID) class NotifiableEventProcessorTest { private val outdatedDetector = FakeOutdatedEventDetector() - private val eventProcessor = NotifiableEventProcessor(outdatedDetector.instance) @Test fun `given simple events when processing then keep simple events`() { @@ -48,8 +51,9 @@ class NotifiableEventProcessorTest { aSimpleNotifiableEvent(eventId = AN_EVENT_ID), aSimpleNotifiableEvent(eventId = AN_EVENT_ID_2) ) + val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM) - val result = eventProcessor.process(events, appNavigationState = NOT_VIEWING_A_ROOM, renderedEvents = emptyList()) + val result = eventProcessor.process(events, renderedEvents = emptyList()) assertThat(result).isEqualTo( listOfProcessedEvents( @@ -62,8 +66,9 @@ class NotifiableEventProcessorTest { @Test fun `given redacted simple event when processing then remove redaction event`() { val events = listOf(aSimpleNotifiableEvent(eventId = AN_EVENT_ID, type = EventType.REDACTION)) + val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM) - val result = eventProcessor.process(events, appNavigationState = NOT_VIEWING_A_ROOM, renderedEvents = emptyList()) + val result = eventProcessor.process(events, renderedEvents = emptyList()) assertThat(result).isEqualTo( listOfProcessedEvents( @@ -78,8 +83,9 @@ class NotifiableEventProcessorTest { anInviteNotifiableEvent(roomId = A_ROOM_ID), anInviteNotifiableEvent(roomId = A_ROOM_ID_2) ) + val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM) - val result = eventProcessor.process(events, appNavigationState = NOT_VIEWING_A_ROOM, renderedEvents = emptyList()) + val result = eventProcessor.process(events, renderedEvents = emptyList()) assertThat(result).isEqualTo( listOfProcessedEvents( @@ -94,7 +100,9 @@ class NotifiableEventProcessorTest { val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID)) outdatedDetector.givenEventIsOutOfDate(events[0]) - val result = eventProcessor.process(events, appNavigationState = NOT_VIEWING_A_ROOM, renderedEvents = emptyList()) + val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM) + + val result = eventProcessor.process(events, renderedEvents = emptyList()) assertThat(result).isEqualTo( listOfProcessedEvents( @@ -107,8 +115,9 @@ class NotifiableEventProcessorTest { fun `given in date message event when processing then keep message event`() { val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID)) outdatedDetector.givenEventIsInDate(events[0]) + val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM) - val result = eventProcessor.process(events, appNavigationState = NOT_VIEWING_A_ROOM, renderedEvents = emptyList()) + val result = eventProcessor.process(events, renderedEvents = emptyList()) assertThat(result).isEqualTo( listOfProcessedEvents( @@ -121,8 +130,9 @@ class NotifiableEventProcessorTest { fun `given viewing the same room main timeline when processing main timeline message event then removes message`() { val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = null)) events.forEach { outdatedDetector.givenEventIsOutOfDate(it) } + val eventProcessor = createProcessor(isInForeground = true, navigationState = VIEWING_A_ROOM) - val result = eventProcessor.process(events, VIEWING_A_ROOM, renderedEvents = emptyList()) + val result = eventProcessor.process(events, renderedEvents = emptyList()) assertThat(result).isEqualTo( listOfProcessedEvents( @@ -135,8 +145,9 @@ class NotifiableEventProcessorTest { fun `given viewing the same thread timeline when processing thread message event then removes message`() { val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = A_THREAD_ID)) events.forEach { outdatedDetector.givenEventIsOutOfDate(it) } + val eventProcessor = createProcessor(isInForeground = true, navigationState = VIEWING_A_THREAD) - val result = eventProcessor.process(events, VIEWING_A_THREAD, renderedEvents = emptyList()) + val result = eventProcessor.process(events, renderedEvents = emptyList()) assertThat(result).isEqualTo( listOfProcessedEvents( @@ -149,8 +160,9 @@ class NotifiableEventProcessorTest { fun `given viewing main timeline of the same room when processing thread timeline message event then keep message`() { val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = A_THREAD_ID)) outdatedDetector.givenEventIsInDate(events[0]) + val eventProcessor = createProcessor(isInForeground = true, navigationState = VIEWING_A_ROOM) - val result = eventProcessor.process(events, VIEWING_A_ROOM, renderedEvents = emptyList()) + val result = eventProcessor.process(events, renderedEvents = emptyList()) assertThat(result).isEqualTo( listOfProcessedEvents( @@ -163,8 +175,9 @@ class NotifiableEventProcessorTest { fun `given viewing thread timeline of the same room when processing main timeline message event then keep message`() { val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID)) outdatedDetector.givenEventIsInDate(events[0]) + val eventProcessor = createProcessor(isInForeground = true, navigationState = VIEWING_A_THREAD) - val result = eventProcessor.process(events, VIEWING_A_THREAD, renderedEvents = emptyList()) + val result = eventProcessor.process(events, renderedEvents = emptyList()) assertThat(result).isEqualTo( listOfProcessedEvents( @@ -180,8 +193,9 @@ class NotifiableEventProcessorTest { ProcessedEvent(ProcessedEvent.Type.KEEP, events[0]), ProcessedEvent(ProcessedEvent.Type.KEEP, anInviteNotifiableEvent(eventId = AN_EVENT_ID_2)) ) + val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM) - val result = eventProcessor.process(events, appNavigationState = NOT_VIEWING_A_ROOM, renderedEvents = renderedEvents) + val result = eventProcessor.process(events, renderedEvents = renderedEvents) assertThat(result).isEqualTo( listOfProcessedEvents( @@ -194,4 +208,14 @@ class NotifiableEventProcessorTest { private fun listOfProcessedEvents(vararg event: Pair) = event.map { ProcessedEvent(it.first, it.second) } + + private fun createProcessor( + isInForeground: Boolean = false, + navigationState: NavigationState + ): NotifiableEventProcessor { + return NotifiableEventProcessor( + outdatedDetector.instance, + FakeAppNavigationStateService(MutableStateFlow(AppNavigationState(navigationState, isInForeground))), + ) + } } diff --git a/services/appnavstate/api/build.gradle.kts b/services/appnavstate/api/build.gradle.kts index b7ce6161fb..9ae81e15aa 100644 --- a/services/appnavstate/api/build.gradle.kts +++ b/services/appnavstate/api/build.gradle.kts @@ -24,5 +24,8 @@ android { dependencies { implementation(libs.coroutines.core) + implementation(libs.androidx.lifecycle.runtime) + implementation(libs.androidx.lifecycle.process) + implementation(libs.androidx.startup) implementation(projects.libraries.matrix.api) } diff --git a/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppForegroundStateService.kt b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppForegroundStateService.kt new file mode 100644 index 0000000000..098769c370 --- /dev/null +++ b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppForegroundStateService.kt @@ -0,0 +1,34 @@ +/* + * 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.services.appnavstate.api + +import kotlinx.coroutines.flow.StateFlow + +/** + * A service that tracks the foreground state of the app. + */ +interface AppForegroundStateService { + /** + * Any updates to the foreground state of the app will be emitted here. + */ + val isInForeground: StateFlow + + /** + * Start observing the foreground state. + */ + fun start() +} diff --git a/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationState.kt b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationState.kt index 5ead00c976..0a6ab692d2 100644 --- a/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationState.kt +++ b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationState.kt @@ -16,43 +16,10 @@ package io.element.android.services.appnavstate.api -import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.matrix.api.core.SpaceId -import io.element.android.libraries.matrix.api.core.ThreadId - /** - * Can represent the current global app navigation state. - * @param owner mostly a Node identifier associated with the state. - * We are using the owner parameter to check when calling onLeaving methods is still using the same owner than his companion onNavigate. - * Why this is needed : for now we rely on lifecycle methods of the node, which are async. - * If you navigate quickly between nodes, onCreate of the new node is called before onDestroy of the previous node. - * So we assume if we don't get the same owner, we can skip the onLeaving action as we already replaced it. + * A wrapper for the current navigation state of the app, along with its foreground/background state. */ -sealed class AppNavigationState(open val owner: String) { - object Root : AppNavigationState("ROOT") - - data class Session( - override val owner: String, - val sessionId: SessionId, - ) : AppNavigationState(owner) - - data class Space( - override val owner: String, - // Can be fake value, if no space is selected - val spaceId: SpaceId, - val parentSession: Session, - ) : AppNavigationState(owner) - - data class Room( - override val owner: String, - val roomId: RoomId, - val parentSpace: Space, - ) : AppNavigationState(owner) - - data class Thread( - override val owner: String, - val threadId: ThreadId, - val parentRoom: Room, - ) : AppNavigationState(owner) -} +data class AppNavigationState( + val navigationState: NavigationState, + val isInForeground: Boolean, +) diff --git a/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationStateExtension.kt b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationStateExtension.kt deleted file mode 100644 index 00fe638a47..0000000000 --- a/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationStateExtension.kt +++ /dev/null @@ -1,62 +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.services.appnavstate.api - -import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.matrix.api.core.SpaceId -import io.element.android.libraries.matrix.api.core.ThreadId - -fun AppNavigationState.currentSessionId(): SessionId? { - return when (this) { - AppNavigationState.Root -> null - is AppNavigationState.Session -> sessionId - is AppNavigationState.Space -> parentSession.sessionId - is AppNavigationState.Room -> parentSpace.parentSession.sessionId - is AppNavigationState.Thread -> parentRoom.parentSpace.parentSession.sessionId - } -} - -fun AppNavigationState.currentSpaceId(): SpaceId? { - return when (this) { - AppNavigationState.Root -> null - is AppNavigationState.Session -> null - is AppNavigationState.Space -> spaceId - is AppNavigationState.Room -> parentSpace.spaceId - is AppNavigationState.Thread -> parentRoom.parentSpace.spaceId - } -} - -fun AppNavigationState.currentRoomId(): RoomId? { - return when (this) { - AppNavigationState.Root -> null - is AppNavigationState.Session -> null - is AppNavigationState.Space -> null - is AppNavigationState.Room -> roomId - is AppNavigationState.Thread -> parentRoom.roomId - } -} - -fun AppNavigationState.currentThreadId(): ThreadId? { - return when (this) { - AppNavigationState.Root -> null - is AppNavigationState.Session -> null - is AppNavigationState.Space -> null - is AppNavigationState.Room -> null - is AppNavigationState.Thread -> threadId - } -} diff --git a/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationStateService.kt b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationStateService.kt index 4bb40b7b75..50e6b3434e 100644 --- a/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationStateService.kt +++ b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationStateService.kt @@ -22,8 +22,11 @@ import io.element.android.libraries.matrix.api.core.SpaceId import io.element.android.libraries.matrix.api.core.ThreadId import kotlinx.coroutines.flow.StateFlow +/** + * A service that tracks the navigation and foreground states of the app. + */ interface AppNavigationStateService { - val appNavigationStateFlow: StateFlow + val appNavigationState: StateFlow fun onNavigateToSession(owner: String, sessionId: SessionId) fun onLeavingSession(owner: String) @@ -37,3 +40,4 @@ interface AppNavigationStateService { fun onNavigateToThread(owner: String, threadId: ThreadId) fun onLeavingThread(owner: String) } + diff --git a/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/NavigationState.kt b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/NavigationState.kt new file mode 100644 index 0000000000..12cd07f05e --- /dev/null +++ b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/NavigationState.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.services.appnavstate.api + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.SpaceId +import io.element.android.libraries.matrix.api.core.ThreadId + +/** + * Can represent the current global app navigation state. + * @param owner mostly a Node identifier associated with the state. + * We are using the owner parameter to check when calling onLeaving methods is still using the same owner than his companion onNavigate. + * Why this is needed : for now we rely on lifecycle methods of the node, which are async. + * If you navigate quickly between nodes, onCreate of the new node is called before onDestroy of the previous node. + * So we assume if we don't get the same owner, we can skip the onLeaving action as we already replaced it. + */ +sealed class NavigationState(open val owner: String) { + object Root : NavigationState("ROOT") + + data class Session( + override val owner: String, + val sessionId: SessionId, + ) : NavigationState(owner) + + data class Space( + override val owner: String, + // Can be fake value, if no space is selected + val spaceId: SpaceId, + val parentSession: Session, + ) : NavigationState(owner) + + data class Room( + override val owner: String, + val roomId: RoomId, + val parentSpace: Space, + ) : NavigationState(owner) + + data class Thread( + override val owner: String, + val threadId: ThreadId, + val parentRoom: Room, + ) : NavigationState(owner) +} diff --git a/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/NavigationStateExtension.kt b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/NavigationStateExtension.kt new file mode 100644 index 0000000000..b399934cac --- /dev/null +++ b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/NavigationStateExtension.kt @@ -0,0 +1,62 @@ +/* + * 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.services.appnavstate.api + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.SpaceId +import io.element.android.libraries.matrix.api.core.ThreadId + +fun NavigationState.currentSessionId(): SessionId? { + return when (this) { + NavigationState.Root -> null + is NavigationState.Session -> sessionId + is NavigationState.Space -> parentSession.sessionId + is NavigationState.Room -> parentSpace.parentSession.sessionId + is NavigationState.Thread -> parentRoom.parentSpace.parentSession.sessionId + } +} + +fun NavigationState.currentSpaceId(): SpaceId? { + return when (this) { + NavigationState.Root -> null + is NavigationState.Session -> null + is NavigationState.Space -> spaceId + is NavigationState.Room -> parentSpace.spaceId + is NavigationState.Thread -> parentRoom.parentSpace.spaceId + } +} + +fun NavigationState.currentRoomId(): RoomId? { + return when (this) { + NavigationState.Root -> null + is NavigationState.Session -> null + is NavigationState.Space -> null + is NavigationState.Room -> roomId + is NavigationState.Thread -> parentRoom.roomId + } +} + +fun NavigationState.currentThreadId(): ThreadId? { + return when (this) { + NavigationState.Root -> null + is NavigationState.Session -> null + is NavigationState.Space -> null + is NavigationState.Room -> null + is NavigationState.Thread -> threadId + } +} diff --git a/services/appnavstate/impl/build.gradle.kts b/services/appnavstate/impl/build.gradle.kts index 4cd39a4c42..4c6973b8da 100644 --- a/services/appnavstate/impl/build.gradle.kts +++ b/services/appnavstate/impl/build.gradle.kts @@ -38,6 +38,7 @@ dependencies { implementation(libs.coroutines.core) implementation(libs.androidx.corektx) + implementation(libs.androidx.lifecycle.process) api(projects.services.appnavstate.api) @@ -45,5 +46,6 @@ dependencies { testImplementation(libs.coroutines.test) testImplementation(libs.test.truth) testImplementation(projects.libraries.matrix.test) + testImplementation(projects.tests.testutils) testImplementation(projects.services.appnavstate.test) } diff --git a/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/DefaultAppForegroundStateService.kt b/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/DefaultAppForegroundStateService.kt new file mode 100644 index 0000000000..27c3f12a6a --- /dev/null +++ b/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/DefaultAppForegroundStateService.kt @@ -0,0 +1,40 @@ +/* + * 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.services.appnavstate.impl + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.ProcessLifecycleOwner +import io.element.android.services.appnavstate.api.AppForegroundStateService +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class DefaultAppForegroundStateService : AppForegroundStateService { + + private val state = MutableStateFlow(false) + override val isInForeground: StateFlow = state + + private val appLifecycle: Lifecycle by lazy { ProcessLifecycleOwner.get().lifecycle } + + override fun start() { + appLifecycle.addObserver(lifecycleObserver) + } + + private val lifecycleObserver = LifecycleEventObserver { _, _ -> state.value = getCurrentState() } + + private fun getCurrentState(): Boolean = appLifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) +} diff --git a/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/DefaultAppNavigationStateService.kt b/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/DefaultAppNavigationStateService.kt index bf20a04b11..9360ce93ec 100644 --- a/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/DefaultAppNavigationStateService.kt +++ b/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/DefaultAppNavigationStateService.kt @@ -24,10 +24,15 @@ import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SpaceId import io.element.android.libraries.matrix.api.core.ThreadId -import io.element.android.services.appnavstate.api.AppNavigationState +import io.element.android.services.appnavstate.api.AppForegroundStateService +import io.element.android.services.appnavstate.api.NavigationState import io.element.android.services.appnavstate.api.AppNavigationStateService +import io.element.android.services.appnavstate.api.AppNavigationState +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -38,113 +43,131 @@ private val loggerTag = LoggerTag("Navigation") */ @ContributesBinding(AppScope::class) @SingleIn(AppScope::class) -class DefaultAppNavigationStateService @Inject constructor() : AppNavigationStateService { +class DefaultAppNavigationStateService @Inject constructor( + private val appForegroundStateService: AppForegroundStateService, + private val coroutineScope: CoroutineScope, +) : AppNavigationStateService { - private val currentAppNavigationState: MutableStateFlow = MutableStateFlow(AppNavigationState.Root) - override val appNavigationStateFlow: StateFlow = currentAppNavigationState + private val state = MutableStateFlow( + AppNavigationState( + navigationState = NavigationState.Root, + isInForeground = true, + ) + ) + override val appNavigationState: StateFlow = state + + init { + coroutineScope.launch { + appForegroundStateService.start() + + appForegroundStateService.isInForeground.collect { isInForeground -> + state.getAndUpdate { it.copy(isInForeground = isInForeground) } + } + } + } override fun onNavigateToSession(owner: String, sessionId: SessionId) { - val currentValue = currentAppNavigationState.value + val currentValue = state.value.navigationState Timber.tag(loggerTag.value).d("Navigating to session $sessionId. Current state: $currentValue") - val newValue: AppNavigationState.Session = when (currentValue) { - is AppNavigationState.Session, - is AppNavigationState.Space, - is AppNavigationState.Room, - is AppNavigationState.Thread, - is AppNavigationState.Root -> AppNavigationState.Session(owner, sessionId) + val newValue: NavigationState.Session = when (currentValue) { + is NavigationState.Session, + is NavigationState.Space, + is NavigationState.Room, + is NavigationState.Thread, + is NavigationState.Root -> NavigationState.Session(owner, sessionId) } - currentAppNavigationState.value = newValue + state.getAndUpdate { it.copy(navigationState = newValue) } } override fun onNavigateToSpace(owner: String, spaceId: SpaceId) { - val currentValue = currentAppNavigationState.value + val currentValue = state.value.navigationState Timber.tag(loggerTag.value).d("Navigating to space $spaceId. Current state: $currentValue") - val newValue: AppNavigationState.Space = when (currentValue) { - AppNavigationState.Root -> error("onNavigateToSession() must be called first") - is AppNavigationState.Session -> AppNavigationState.Space(owner, spaceId, currentValue) - is AppNavigationState.Space -> AppNavigationState.Space(owner, spaceId, currentValue.parentSession) - is AppNavigationState.Room -> AppNavigationState.Space(owner, spaceId, currentValue.parentSpace.parentSession) - is AppNavigationState.Thread -> AppNavigationState.Space(owner, spaceId, currentValue.parentRoom.parentSpace.parentSession) + val newValue: NavigationState.Space = when (currentValue) { + NavigationState.Root -> error("onNavigateToSession() must be called first") + is NavigationState.Session -> NavigationState.Space(owner, spaceId, currentValue) + is NavigationState.Space -> NavigationState.Space(owner, spaceId, currentValue.parentSession) + is NavigationState.Room -> NavigationState.Space(owner, spaceId, currentValue.parentSpace.parentSession) + is NavigationState.Thread -> NavigationState.Space(owner, spaceId, currentValue.parentRoom.parentSpace.parentSession) } - currentAppNavigationState.value = newValue + state.getAndUpdate { it.copy(navigationState = newValue) } } override fun onNavigateToRoom(owner: String, roomId: RoomId) { - val currentValue = currentAppNavigationState.value + val currentValue = state.value.navigationState Timber.tag(loggerTag.value).d("Navigating to room $roomId. Current state: $currentValue") - val newValue: AppNavigationState.Room = when (currentValue) { - AppNavigationState.Root -> error("onNavigateToSession() must be called first") - is AppNavigationState.Session -> error("onNavigateToSpace() must be called first") - is AppNavigationState.Space -> AppNavigationState.Room(owner, roomId, currentValue) - is AppNavigationState.Room -> AppNavigationState.Room(owner, roomId, currentValue.parentSpace) - is AppNavigationState.Thread -> AppNavigationState.Room(owner, roomId, currentValue.parentRoom.parentSpace) + val newValue: NavigationState.Room = when (currentValue) { + NavigationState.Root -> error("onNavigateToSession() must be called first") + is NavigationState.Session -> error("onNavigateToSpace() must be called first") + is NavigationState.Space -> NavigationState.Room(owner, roomId, currentValue) + is NavigationState.Room -> NavigationState.Room(owner, roomId, currentValue.parentSpace) + is NavigationState.Thread -> NavigationState.Room(owner, roomId, currentValue.parentRoom.parentSpace) } - currentAppNavigationState.value = newValue + state.getAndUpdate { it.copy(navigationState = newValue) } } override fun onNavigateToThread(owner: String, threadId: ThreadId) { - val currentValue = currentAppNavigationState.value + val currentValue = state.value.navigationState Timber.tag(loggerTag.value).d("Navigating to thread $threadId. Current state: $currentValue") - val newValue: AppNavigationState.Thread = when (currentValue) { - AppNavigationState.Root -> error("onNavigateToSession() must be called first") - is AppNavigationState.Session -> error("onNavigateToSpace() must be called first") - is AppNavigationState.Space -> error("onNavigateToRoom() must be called first") - is AppNavigationState.Room -> AppNavigationState.Thread(owner, threadId, currentValue) - is AppNavigationState.Thread -> AppNavigationState.Thread(owner, threadId, currentValue.parentRoom) + val newValue: NavigationState.Thread = when (currentValue) { + NavigationState.Root -> error("onNavigateToSession() must be called first") + is NavigationState.Session -> error("onNavigateToSpace() must be called first") + is NavigationState.Space -> error("onNavigateToRoom() must be called first") + is NavigationState.Room -> NavigationState.Thread(owner, threadId, currentValue) + is NavigationState.Thread -> NavigationState.Thread(owner, threadId, currentValue.parentRoom) } - currentAppNavigationState.value = newValue + state.getAndUpdate { it.copy(navigationState = newValue) } } override fun onLeavingThread(owner: String) { - val currentValue = currentAppNavigationState.value + val currentValue = state.value.navigationState Timber.tag(loggerTag.value).d("Leaving thread. Current state: $currentValue") if (!currentValue.assertOwner(owner)) return - val newValue: AppNavigationState.Room = when (currentValue) { - AppNavigationState.Root -> error("onNavigateToSession() must be called first") - is AppNavigationState.Session -> error("onNavigateToSpace() must be called first") - is AppNavigationState.Space -> error("onNavigateToRoom() must be called first") - is AppNavigationState.Room -> error("onNavigateToThread() must be called first") - is AppNavigationState.Thread -> currentValue.parentRoom + val newValue: NavigationState.Room = when (currentValue) { + NavigationState.Root -> error("onNavigateToSession() must be called first") + is NavigationState.Session -> error("onNavigateToSpace() must be called first") + is NavigationState.Space -> error("onNavigateToRoom() must be called first") + is NavigationState.Room -> error("onNavigateToThread() must be called first") + is NavigationState.Thread -> currentValue.parentRoom } - currentAppNavigationState.value = newValue + state.getAndUpdate { it.copy(navigationState = newValue) } } override fun onLeavingRoom(owner: String) { - val currentValue = currentAppNavigationState.value + val currentValue = state.value.navigationState Timber.tag(loggerTag.value).d("Leaving room. Current state: $currentValue") if (!currentValue.assertOwner(owner)) return - val newValue: AppNavigationState.Space = when (currentValue) { - AppNavigationState.Root -> error("onNavigateToSession() must be called first") - is AppNavigationState.Session -> error("onNavigateToSpace() must be called first") - is AppNavigationState.Space -> error("onNavigateToRoom() must be called first") - is AppNavigationState.Room -> currentValue.parentSpace - is AppNavigationState.Thread -> currentValue.parentRoom.parentSpace + val newValue: NavigationState.Space = when (currentValue) { + NavigationState.Root -> error("onNavigateToSession() must be called first") + is NavigationState.Session -> error("onNavigateToSpace() must be called first") + is NavigationState.Space -> error("onNavigateToRoom() must be called first") + is NavigationState.Room -> currentValue.parentSpace + is NavigationState.Thread -> currentValue.parentRoom.parentSpace } - currentAppNavigationState.value = newValue + state.getAndUpdate { it.copy(navigationState = newValue) } } override fun onLeavingSpace(owner: String) { - val currentValue = currentAppNavigationState.value + val currentValue = state.value.navigationState Timber.tag(loggerTag.value).d("Leaving space. Current state: $currentValue") if (!currentValue.assertOwner(owner)) return - val newValue: AppNavigationState.Session = when (currentValue) { - AppNavigationState.Root -> error("onNavigateToSession() must be called first") - is AppNavigationState.Session -> error("onNavigateToSpace() must be called first") - is AppNavigationState.Space -> currentValue.parentSession - is AppNavigationState.Room -> currentValue.parentSpace.parentSession - is AppNavigationState.Thread -> currentValue.parentRoom.parentSpace.parentSession + val newValue: NavigationState.Session = when (currentValue) { + NavigationState.Root -> error("onNavigateToSession() must be called first") + is NavigationState.Session -> error("onNavigateToSpace() must be called first") + is NavigationState.Space -> currentValue.parentSession + is NavigationState.Room -> currentValue.parentSpace.parentSession + is NavigationState.Thread -> currentValue.parentRoom.parentSpace.parentSession } - currentAppNavigationState.value = newValue + state.getAndUpdate { it.copy(navigationState = newValue) } } override fun onLeavingSession(owner: String) { - val currentValue = currentAppNavigationState.value + val currentValue = state.value.navigationState Timber.tag(loggerTag.value).d("Leaving session. Current state: $currentValue") if (!currentValue.assertOwner(owner)) return - currentAppNavigationState.value = AppNavigationState.Root + state.getAndUpdate { it.copy(navigationState = NavigationState.Root) } } - private fun AppNavigationState.assertOwner(owner: String): Boolean { + private fun NavigationState.assertOwner(owner: String): Boolean { if (this.owner != owner) { Timber.tag(loggerTag.value).d("Can't leave current state as the owner is not the same (current = ${this.owner}, new = $owner)") return false diff --git a/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/di/AppNavStateModule.kt b/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/di/AppNavStateModule.kt new file mode 100644 index 0000000000..4537c9f902 --- /dev/null +++ b/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/di/AppNavStateModule.kt @@ -0,0 +1,39 @@ +/* + * 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.services.appnavstate.impl.di + +import android.content.Context +import androidx.startup.AppInitializer +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import io.element.android.services.appnavstate.api.AppForegroundStateService +import io.element.android.services.appnavstate.impl.initializer.AppForegroundStateServiceInitializer + +@Module +@ContributesTo(AppScope::class) +object AppNavStateModule { + + @Provides + fun provideAppForegroundStateService( + @ApplicationContext context: Context + ): AppForegroundStateService = + AppInitializer.getInstance(context).initializeComponent(AppForegroundStateServiceInitializer::class.java) + +} diff --git a/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/initializer/AppForegroundStateServiceInitializer.kt b/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/initializer/AppForegroundStateServiceInitializer.kt new file mode 100644 index 0000000000..cfd382a57b --- /dev/null +++ b/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/initializer/AppForegroundStateServiceInitializer.kt @@ -0,0 +1,33 @@ +/* + * 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.services.appnavstate.impl.initializer + +import android.content.Context +import androidx.lifecycle.ProcessLifecycleInitializer +import androidx.startup.Initializer +import io.element.android.services.appnavstate.api.AppForegroundStateService +import io.element.android.services.appnavstate.impl.DefaultAppForegroundStateService + +class AppForegroundStateServiceInitializer : Initializer { + override fun create(context: Context): AppForegroundStateService { + return DefaultAppForegroundStateService() + } + + override fun dependencies(): MutableList>> = mutableListOf( + ProcessLifecycleInitializer::class.java + ) +} diff --git a/services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/DefaultAppNavigationStateServiceTest.kt b/services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/DefaultNavigationStateServiceTest.kt similarity index 70% rename from services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/DefaultAppNavigationStateServiceTest.kt rename to services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/DefaultNavigationStateServiceTest.kt index d6000dc1d8..dd0e576c79 100644 --- a/services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/DefaultAppNavigationStateServiceTest.kt +++ b/services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/DefaultNavigationStateServiceTest.kt @@ -21,35 +21,36 @@ 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_SPACE_ID import io.element.android.libraries.matrix.test.A_THREAD_ID -import io.element.android.services.appnavstate.api.AppNavigationState +import io.element.android.services.appnavstate.api.NavigationState import io.element.android.services.appnavstate.test.A_ROOM_OWNER import io.element.android.services.appnavstate.test.A_SESSION_OWNER import io.element.android.services.appnavstate.test.A_SPACE_OWNER import io.element.android.services.appnavstate.test.A_THREAD_OWNER +import io.element.android.tests.testutils.runCancellableScopeTest +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.runTest import org.junit.Assert.assertThrows import org.junit.Test -class DefaultAppNavigationStateServiceTest { +class DefaultNavigationStateServiceTest { @Test - fun testNavigation() = runTest { - val service = DefaultAppNavigationStateService() + fun testNavigation() = runCancellableScopeTest { scope -> + val service = createStateService(scope) service.onNavigateToSession(A_SESSION_OWNER, A_SESSION_ID) service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID) service.onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID) service.onNavigateToThread(A_THREAD_OWNER, A_THREAD_ID) - assertThat(service.appNavigationStateFlow.first()).isEqualTo( - AppNavigationState.Thread( + assertThat(service.appNavigationState.first().navigationState).isEqualTo( + NavigationState.Thread( A_THREAD_OWNER, A_THREAD_ID, - AppNavigationState.Room( + NavigationState.Room( A_ROOM_OWNER, A_ROOM_ID, - AppNavigationState.Space( + NavigationState.Space( A_SPACE_OWNER, A_SPACE_ID, - AppNavigationState.Session( + NavigationState.Session( A_SESSION_OWNER, A_SESSION_ID ) @@ -60,8 +61,13 @@ class DefaultAppNavigationStateServiceTest { } @Test - fun testFailure() = runTest { - val service = DefaultAppNavigationStateService() + fun testFailure() = runCancellableScopeTest { scope -> + val service = createStateService(scope) + assertThrows(IllegalStateException::class.java) { service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID) } } + + private fun createStateService( + coroutineScope: CoroutineScope + ) = DefaultAppNavigationStateService(FakeAppForegroundStateService(), coroutineScope) } diff --git a/services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/FakeAppForegroundStateService.kt b/services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/FakeAppForegroundStateService.kt new file mode 100644 index 0000000000..e243523bd0 --- /dev/null +++ b/services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/FakeAppForegroundStateService.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.services.appnavstate.impl + +import io.element.android.services.appnavstate.api.AppForegroundStateService +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class FakeAppForegroundStateService( + initialValue: Boolean = true, +) : AppForegroundStateService { + + private val state = MutableStateFlow(initialValue) + override val isInForeground: StateFlow = state + + override fun start() { + // No-op + } + + fun givenIsInForeground(isInForeground: Boolean) { + state.value = isInForeground + } +} diff --git a/services/appnavstate/test/build.gradle.kts b/services/appnavstate/test/build.gradle.kts index 93e9294304..656777dac1 100644 --- a/services/appnavstate/test/build.gradle.kts +++ b/services/appnavstate/test/build.gradle.kts @@ -26,4 +26,5 @@ dependencies { api(projects.libraries.matrix.api) api(projects.services.appnavstate.api) implementation(libs.coroutines.core) + implementation(libs.androidx.lifecycle.runtime) } diff --git a/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/AppNavStateFixture.kt b/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/AppNavStateFixture.kt index aa0b351220..63c3d4e967 100644 --- a/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/AppNavStateFixture.kt +++ b/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/AppNavStateFixture.kt @@ -21,33 +21,33 @@ import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SpaceId import io.element.android.libraries.matrix.api.core.ThreadId -import io.element.android.services.appnavstate.api.AppNavigationState +import io.element.android.services.appnavstate.api.NavigationState const val A_SESSION_OWNER = "aSessionOwner" const val A_SPACE_OWNER = "aSpaceOwner" const val A_ROOM_OWNER = "aRoomOwner" const val A_THREAD_OWNER = "aThreadOwner" -fun anAppNavigationState( +fun aNavigationState( sessionId: SessionId? = null, spaceId: SpaceId? = MAIN_SPACE, roomId: RoomId? = null, threadId: ThreadId? = null, -): AppNavigationState { +): NavigationState { if (sessionId == null) { - return AppNavigationState.Root + return NavigationState.Root } - val session = AppNavigationState.Session(A_SESSION_OWNER, sessionId) + val session = NavigationState.Session(A_SESSION_OWNER, sessionId) if (spaceId == null) { return session } - val space = AppNavigationState.Space(A_SPACE_OWNER, spaceId, session) + val space = NavigationState.Space(A_SPACE_OWNER, spaceId, session) if (roomId == null) { return space } - val room = AppNavigationState.Room(A_ROOM_OWNER, roomId, space) + val room = NavigationState.Room(A_ROOM_OWNER, roomId, space) if (threadId == null) { return room } - return AppNavigationState.Thread(A_THREAD_OWNER, threadId, room) + return NavigationState.Thread(A_THREAD_OWNER, threadId, room) } diff --git a/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/NoopAppNavigationStateService.kt b/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/FakeAppNavigationStateService.kt similarity index 78% rename from services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/NoopAppNavigationStateService.kt rename to services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/FakeAppNavigationStateService.kt index c31d74ec18..a09e2a9c5e 100644 --- a/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/NoopAppNavigationStateService.kt +++ b/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/FakeAppNavigationStateService.kt @@ -20,16 +20,22 @@ import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SpaceId import io.element.android.libraries.matrix.api.core.ThreadId -import io.element.android.services.appnavstate.api.AppNavigationState +import io.element.android.services.appnavstate.api.NavigationState import io.element.android.services.appnavstate.api.AppNavigationStateService +import io.element.android.services.appnavstate.api.AppNavigationState import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -class NoopAppNavigationStateService : AppNavigationStateService { +class FakeAppNavigationStateService( + private val fakeAppNavigationState: MutableStateFlow = MutableStateFlow( + AppNavigationState( + navigationState = NavigationState.Root, + isInForeground = true, + ) + ), +) : AppNavigationStateService { - private val currentAppNavigationState: MutableStateFlow = - MutableStateFlow(AppNavigationState.Root) - override val appNavigationStateFlow: StateFlow = currentAppNavigationState + override val appNavigationState: StateFlow = fakeAppNavigationState override fun onNavigateToSession(owner: String, sessionId: SessionId) = Unit override fun onLeavingSession(owner: String) = Unit diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/RunCancellableTest.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/RunCancellableTest.kt new file mode 100644 index 0000000000..aea33b6798 --- /dev/null +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/RunCancellableTest.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.tests.testutils + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.runTest + +/** + * Run a test with a [CoroutineScope] that will be cancelled automatically and avoiding failing the test. + */ +fun runCancellableScopeTest(block: suspend (CoroutineScope) -> Unit) = runTest { + val scope = CoroutineScope(coroutineContext + SupervisorJob()) + block(scope) + scope.cancel() +} From 778155bb9e80d0d0baee8b0b41f5892634e49cf1 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 17 Jul 2023 17:14:41 +0200 Subject: [PATCH 25/42] Save text in composer when navigating to a sub node (opening an image from the timeline for instance). Fixes #870. --- .../messages/impl/messagecomposer/MessageComposerPresenter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 4d749b465e..020236e890 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -92,7 +92,7 @@ class MessageComposerPresenter @Inject constructor( val hasFocus = remember { mutableStateOf(false) } - val text: MutableState = remember { + val text: MutableState = rememberSaveable { mutableStateOf("") } From 280b7e32e0ad5408275b777f04f3da75d81f049a Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Mon, 17 Jul 2023 18:34:36 +0200 Subject: [PATCH 26/42] Hide encryption history + FTUE flow (#839) * First attempt at implementing encrypted history banner and removing old UTDs * Get the right behavior in the timeline * Implement the designs * Extract post-processing logic, add tests * Add encryption banner to timeline screenshots * Create FTUE feature to handle welcome screen and analytics * Move classes to their own packages, add tests for `DefaultFtueState`. * Remove unnecessary private MutableStateFlow * Move some FTUE related methods and classes back to the `impl` module * Handle back press at each FTUE step * Remove unneeded `TestScope` receiver for `createState` in tests. * Use light & dark previews for the banner view. * Move color customization from `TextStyle` to `Text` component. * Rename `InfoList` design components, use them in `AnalyticsOptInView` too. * Cleanup MatrixClient. * Fix copy&paste error Co-authored-by: Benoit Marty * Fix typo * Fix Maestro tests --------- Co-authored-by: ElementBot Co-authored-by: Benoit Marty --- .maestro/tests/account/login.yaml | 2 + .../assertWelcomeScreenDisplayed.yaml | 6 + appnav/build.gradle.kts | 2 + .../android/appnav/LoggedInFlowNode.kt | 48 ++--- build.gradle.kts | 4 +- .../analytics/impl/AnalyticsOptInView.kt | 116 ++++++------ features/ftue/api/build.gradle.kts | 27 +++ .../features/ftue/api/FtueEntryPoint.kt | 36 ++++ .../features/ftue/api/state/FtueState.kt | 23 +++ features/ftue/impl/build.gradle.kts | 55 ++++++ .../ftue/impl/DefaultFtueEntryPoint.kt | 46 +++++ .../features/ftue/impl/FtueFlowNode.kt | 154 ++++++++++++++++ .../ftue/impl/state/DefaultFtueState.kt | 89 +++++++++ .../features/ftue/impl/welcome/WelcomeNode.kt | 54 ++++++ .../features/ftue/impl/welcome/WelcomeView.kt | 128 +++++++++++++ .../state/AndroidWelcomeScreenState.kt | 43 +++++ .../impl/welcome/state/WelcomeScreenState.kt | 22 +++ .../impl/src/main/res/values/localazy.xml | 9 + .../ftue/impl/DefaultFtueStateTests.kt | 115 ++++++++++++ .../impl/welcome/state/FakeWelcomeState.kt | 30 ++++ .../res/drawable/onboarding_icon_light.png | Bin 0 -> 44244 bytes .../messages/impl/timeline/TimelineView.kt | 3 + .../components/TimelineItemVirtualRow.kt | 5 +- .../TimelineEncryptedHistoryBannerView.kt | 69 +++++++ .../factories/TimelineItemsFactory.kt | 1 - .../virtual/TimelineItemVirtualFactory.kt | 9 +- ...eItemEncryptedHistoryBannerVirtualModel.kt | 21 +++ .../atomic/atoms/ElementLogoAtom.kt | 170 ++++++++++++++++++ .../atomic/atoms/InfoListItemMolecule.kt | 113 ++++++++++++ .../atomic/molecules/InfoListOrganism.kt | 79 ++++++++ .../atomic/pages/OnBoardingPage.kt | 3 + .../src/main/res/drawable/element_logo.xml | 26 +++ .../libraries/matrix/api/MatrixClient.kt | 2 - .../item/virtual/VirtualTimelineItem.kt | 1 + libraries/matrix/impl/build.gradle.kts | 4 + .../libraries/matrix/impl/RustMatrixClient.kt | 1 + .../auth/RustMatrixAuthenticationService.kt | 2 + .../matrix/impl/room/RustMatrixRoom.kt | 6 +- .../impl/timeline/RustMatrixTimeline.kt | 29 ++- .../TimelineEncryptedHistoryPostProcessor.kt | 74 ++++++++ ...melineEncryptedHistoryPostProcessorTest.kt | 115 ++++++++++++ .../sessionstorage/api/SessionData.kt | 5 +- .../session-storage/impl/build.gradle.kts | 4 +- .../sessionstorage/impl/SessionDataMapper.kt | 10 +- .../libraries/matrix/session/SessionData.sq | 6 +- .../impl/src/main/sqldelight/migrations/0.sqm | 8 + .../impl/src/main/sqldelight/migrations/1.sqm | 1 + .../impl/DatabaseSessionStoreTests.kt | 3 +- .../android/libraries/testtags/TestTags.kt | 5 + .../src/main/res/values-de/translations.xml | 6 - .../src/main/res/values-fr/translations.xml | 5 - .../src/main/res/values-sk/translations.xml | 6 - .../src/main/res/values/localazy.xml | 6 - ...ViewPreview-D-0_1_null,NEXUS_5,1.0,en].png | 3 + ...ViewPreview-N-0_2_null,NEXUS_5,1.0,en].png | 3 + ...ViewPreview-D-7_8_null,NEXUS_5,1.0,en].png | 3 + ...ViewPreview-N-7_9_null,NEXUS_5,1.0,en].png | 3 + ...goAtomPreview-D_0_null,NEXUS_5,1.0,en].png | 3 + ...goAtomPreview-N_1_null,NEXUS_5,1.0,en].png | 3 + ...leculePreview-D_0_null,NEXUS_5,1.0,en].png | 3 + ...leculePreview-N_1_null,NEXUS_5,1.0,en].png | 3 + tools/localazy/config.json | 6 + 62 files changed, 1714 insertions(+), 123 deletions(-) create mode 100644 .maestro/tests/assertions/assertWelcomeScreenDisplayed.yaml create mode 100644 features/ftue/api/build.gradle.kts create mode 100644 features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/FtueEntryPoint.kt create mode 100644 features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/state/FtueState.kt create mode 100644 features/ftue/impl/build.gradle.kts create mode 100644 features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/DefaultFtueEntryPoint.kt create mode 100644 features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt create mode 100644 features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueState.kt create mode 100644 features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeNode.kt create mode 100644 features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeView.kt create mode 100644 features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/AndroidWelcomeScreenState.kt create mode 100644 features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/WelcomeScreenState.kt create mode 100644 features/ftue/impl/src/main/res/values/localazy.xml create mode 100644 features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueStateTests.kt create mode 100644 features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/FakeWelcomeState.kt create mode 100644 features/login/impl/src/main/res/drawable/onboarding_icon_light.png create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineEncryptedHistoryBannerView.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemEncryptedHistoryBannerVirtualModel.kt create mode 100644 libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/ElementLogoAtom.kt create mode 100644 libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/InfoListItemMolecule.kt create mode 100644 libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/InfoListOrganism.kt create mode 100644 libraries/designsystem/src/main/res/drawable/element_logo.xml create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessor.kt create mode 100644 libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessorTest.kt create mode 100644 libraries/session-storage/impl/src/main/sqldelight/migrations/0.sqm create mode 100644 libraries/session-storage/impl/src/main/sqldelight/migrations/1.sqm create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.ftue.impl.welcome_null_DefaultGroup_WelcomeViewPreview-D-0_1_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.ftue.impl.welcome_null_DefaultGroup_WelcomeViewPreview-N-0_2_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineEncryptedHistoryBannerViewPreview-D-7_8_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineEncryptedHistoryBannerViewPreview-N-7_9_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_ElementLogoAtomPreview-D_0_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_ElementLogoAtomPreview-N_1_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_InfoListItemMoleculePreview-D_0_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_InfoListItemMoleculePreview-N_1_null,NEXUS_5,1.0,en].png diff --git a/.maestro/tests/account/login.yaml b/.maestro/tests/account/login.yaml index 70a9b956ef..6126e34459 100644 --- a/.maestro/tests/account/login.yaml +++ b/.maestro/tests/account/login.yaml @@ -23,6 +23,8 @@ appId: ${APP_ID} - inputText: ${PASSWORD} - pressKey: Enter - tapOn: "Continue" +- runFlow: ../assertions/assertWelcomeScreenDisplayed.yaml +- tapOn: "Continue" - runFlow: ../assertions/assertAnalyticsDisplayed.yaml - tapOn: "Not now" - runFlow: ../assertions/assertHomeDisplayed.yaml diff --git a/.maestro/tests/assertions/assertWelcomeScreenDisplayed.yaml b/.maestro/tests/assertions/assertWelcomeScreenDisplayed.yaml new file mode 100644 index 0000000000..73e8e78ef5 --- /dev/null +++ b/.maestro/tests/assertions/assertWelcomeScreenDisplayed.yaml @@ -0,0 +1,6 @@ +appId: ${APP_ID} +--- +- extendedWaitUntil: + visible: + id: "welcome_screen-title" + timeout: 10_000 diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index aa21c1f6d1..459acdeac6 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -54,6 +54,8 @@ dependencies { implementation(projects.tests.uitests) implementation(libs.coil) + implementation(projects.features.ftue.api) + implementation(projects.services.apperror.impl) implementation(projects.services.appnavstate.api) implementation(projects.services.analytics.api) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index 3380dd91db..48a0743447 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -19,6 +19,8 @@ package io.element.android.appnav import android.os.Parcelable import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope @@ -41,7 +43,6 @@ import io.element.android.anvilannotations.ContributesNode import io.element.android.appnav.loggedin.LoggedInNode import io.element.android.appnav.room.RoomFlowNode import io.element.android.appnav.room.RoomLoadedFlowNode -import io.element.android.features.analytics.api.AnalyticsEntryPoint import io.element.android.features.createroom.api.CreateRoomEntryPoint import io.element.android.features.invitelist.api.InviteListEntryPoint import io.element.android.features.networkmonitor.api.NetworkMonitor @@ -49,6 +50,8 @@ import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.preferences.api.PreferencesEntryPoint import io.element.android.features.roomlist.api.RoomListEntryPoint import io.element.android.features.verifysession.api.VerifySessionEntryPoint +import io.element.android.features.ftue.api.FtueEntryPoint +import io.element.android.features.ftue.api.state.FtueState import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler @@ -64,13 +67,10 @@ import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.libraries.matrix.ui.di.MatrixUIBindings import io.element.android.libraries.push.api.notifications.NotificationDrawerManager -import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.appnavstate.api.AppNavigationStateService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize @@ -81,14 +81,14 @@ class LoggedInFlowNode @AssistedInject constructor( private val roomListEntryPoint: RoomListEntryPoint, private val preferencesEntryPoint: PreferencesEntryPoint, private val createRoomEntryPoint: CreateRoomEntryPoint, - private val analyticsOptInEntryPoint: AnalyticsEntryPoint, private val appNavigationStateService: AppNavigationStateService, private val verifySessionEntryPoint: VerifySessionEntryPoint, private val inviteListEntryPoint: InviteListEntryPoint, - private val analyticsService: AnalyticsService, + private val ftueEntryPoint: FtueEntryPoint, private val coroutineScope: CoroutineScope, private val networkMonitor: NetworkMonitor, private val notificationDrawerManager: NotificationDrawerManager, + private val ftueState: FtueState, snackbarDispatcher: SnackbarDispatcher, ) : BackstackNode( backstack = BackStack( @@ -99,19 +99,6 @@ class LoggedInFlowNode @AssistedInject constructor( plugins = plugins ) { - private fun observeAnalyticsState() { - analyticsService.didAskUserConsent() - .distinctUntilChanged() - .onEach { isConsentAsked -> - if (isConsentAsked) { - backstack.removeLast(NavTarget.AnalyticsOptIn) - } else { - backstack.push(NavTarget.AnalyticsOptIn) - } - } - .launchIn(lifecycleScope) - } - interface Callback : Plugin { fun onOpenBugReport() = Unit } @@ -136,7 +123,7 @@ class LoggedInFlowNode @AssistedInject constructor( override fun onBuilt() { super.onBuilt() - observeAnalyticsState() + lifecycle.subscribe( onCreate = { plugins().forEach { it.onFlowCreated(id, inputs.matrixClient) } @@ -146,6 +133,10 @@ class LoggedInFlowNode @AssistedInject constructor( // TODO We do not support Space yet, so directly navigate to main space appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE) loggedInFlowProcessor.observeEvents(coroutineScope) + + if (ftueState.shouldDisplayFlow.value) { + backstack.push(NavTarget.Ftue) + } }, onResume = { syncService.startSync() @@ -209,7 +200,7 @@ class LoggedInFlowNode @AssistedInject constructor( object InviteList : NavTarget @Parcelize - object AnalyticsOptIn : NavTarget + object Ftue : NavTarget } override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { @@ -306,8 +297,13 @@ class LoggedInFlowNode @AssistedInject constructor( .callback(callback) .build() } - NavTarget.AnalyticsOptIn -> { - analyticsOptInEntryPoint.createNode(this, buildContext) + NavTarget.Ftue -> { + ftueEntryPoint.nodeBuilder(this, buildContext) + .callback(object : FtueEntryPoint.Callback { + override fun onFtueFlowFinished() { + backstack.pop() + } + }).build() } } } @@ -335,7 +331,11 @@ class LoggedInFlowNode @AssistedInject constructor( transitionHandler = rememberDefaultTransitionHandler(), ) - PermanentChild(navTarget = NavTarget.Permanent) + val isFtueDisplayed by ftueState.shouldDisplayFlow.collectAsState() + + if (!isFtueDisplayed) { + PermanentChild(navTarget = NavTarget.Permanent) + } } } diff --git a/build.gradle.kts b/build.gradle.kts index 02c3ca3043..722e83fe87 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -246,7 +246,7 @@ koverMerged { name = "Check code coverage of states" target = kotlinx.kover.api.VerificationTarget.CLASS overrideClassFilter { - includes += "*State" + includes += "^*State$" excludes += "io.element.android.libraries.matrix.api.timeline.item.event.OtherState$*" excludes += "io.element.android.libraries.matrix.api.timeline.item.event.EventSendState$*" excludes += "io.element.android.libraries.matrix.api.room.RoomMembershipState*" @@ -262,6 +262,8 @@ koverMerged { excludes += "io.element.android.libraries.maplibre.compose.CameraPositionState*" excludes += "io.element.android.libraries.maplibre.compose.SaveableCameraPositionState" excludes += "io.element.android.libraries.maplibre.compose.SymbolState*" + excludes += "io.element.android.features.ftue.api.state.*" + excludes += "io.element.android.features.ftue.impl.welcome.state.*" } bound { minValue = 90 diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt index b9fe17d237..ba6d84ae74 100644 --- a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt +++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt @@ -16,12 +16,11 @@ package io.element.android.features.analytics.impl +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding @@ -48,6 +47,8 @@ import androidx.compose.ui.unit.dp import io.element.android.features.analytics.api.AnalyticsOptInEvents import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.atomic.molecules.InfoListItem +import io.element.android.libraries.designsystem.atomic.molecules.InfoListOrganism import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight @@ -60,6 +61,7 @@ import io.element.android.libraries.designsystem.theme.temporaryColorBgSpecial import io.element.android.libraries.designsystem.utils.LogCompositions import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.persistentListOf @Composable fun AnalyticsOptInView( @@ -69,6 +71,16 @@ fun AnalyticsOptInView( ) { LogCompositions(tag = "Analytics", msg = "Root") val eventSink = state.eventSink + + fun onTermsAccepted() { + eventSink(AnalyticsOptInEvents.EnableAnalytics(true)) + } + + fun onTermsDeclined() { + eventSink(AnalyticsOptInEvents.EnableAnalytics(false)) + } + + BackHandler(onBack = ::onTermsDeclined) HeaderFooterPage( modifier = modifier .fillMaxSize() @@ -76,7 +88,13 @@ fun AnalyticsOptInView( .imePadding(), header = { AnalyticsOptInHeader(state, onClickTerms) }, content = { AnalyticsOptInContent() }, - footer = { AnalyticsOptInFooter(eventSink) }) + footer = { + AnalyticsOptInFooter( + onTermsAccepted = ::onTermsAccepted, + onTermsDeclined = ::onTermsDeclined, + ) + } + ) } @Composable @@ -114,6 +132,19 @@ private fun AnalyticsOptInHeader( } } +@Composable +private fun CheckIcon(modifier: Modifier = Modifier) { + Icon( + modifier = Modifier + .size(20.dp) + .background(color = MaterialTheme.colorScheme.background, shape = CircleShape) + .padding(2.dp), + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = ElementTheme.colors.textActionAccent, + ) +} + @Composable private fun AnalyticsOptInContent( modifier: Modifier = Modifier, @@ -125,80 +156,45 @@ private fun AnalyticsOptInContent( verticalBias = -0.4f ) ) { - Column( - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - AnalyticsOptInContentRow( - text = stringResource(id = R.string.screen_analytics_prompt_data_usage), - idx = 0 - ) - AnalyticsOptInContentRow( - text = stringResource(id = R.string.screen_analytics_prompt_third_party_sharing), - idx = 1 - ) - AnalyticsOptInContentRow( - text = stringResource(id = R.string.screen_analytics_prompt_settings), - idx = 2 - ) - } - } -} - -@Composable -private fun AnalyticsOptInContentRow( - text: String, - idx: Int, - modifier: Modifier = Modifier, -) { - val radius = 14.dp - val bgShape = when (idx) { - 0 -> RoundedCornerShape(topStart = radius, topEnd = radius) - 2 -> RoundedCornerShape(bottomStart = radius, bottomEnd = radius) - else -> RoundedCornerShape(0.dp) - } - Row( - modifier = modifier - .fillMaxWidth() - .background( - color = ElementTheme.colors.temporaryColorBgSpecial, - shape = bgShape, - ) - .padding(vertical = 12.dp, horizontal = 20.dp), - ) { - Icon( - modifier = Modifier - .size(20.dp) - .background(color = MaterialTheme.colorScheme.background, shape = CircleShape) - .padding(2.dp), - imageVector = Icons.Rounded.Check, - contentDescription = null, - tint = ElementTheme.colors.textActionAccent, - ) - Text( - modifier = Modifier.padding(start = 16.dp), - text = text, - style = ElementTheme.typography.fontBodyMdMedium, - color = MaterialTheme.colorScheme.primary, + InfoListOrganism( + items = persistentListOf( + InfoListItem( + message = stringResource(id = R.string.screen_analytics_prompt_data_usage), + iconComposable = { CheckIcon() }, + ), + InfoListItem( + message = stringResource(id = R.string.screen_analytics_prompt_third_party_sharing), + iconComposable = { CheckIcon() }, + ), + InfoListItem( + message = stringResource(id = R.string.screen_analytics_prompt_settings), + iconComposable = { CheckIcon() }, + ), + ), + textStyle = ElementTheme.typography.fontBodyMdMedium, + iconTint = ElementTheme.colors.textPrimary, + backgroundColor = ElementTheme.colors.temporaryColorBgSpecial ) } } @Composable private fun AnalyticsOptInFooter( - eventSink: (AnalyticsOptInEvents) -> Unit, + onTermsAccepted: () -> Unit, + onTermsDeclined: () -> Unit, modifier: Modifier = Modifier, ) { ButtonColumnMolecule( modifier = modifier, ) { Button( - onClick = { eventSink(AnalyticsOptInEvents.EnableAnalytics(true)) }, + onClick = onTermsAccepted, modifier = Modifier.fillMaxWidth(), ) { Text(text = stringResource(id = CommonStrings.action_ok)) } TextButton( - onClick = { eventSink(AnalyticsOptInEvents.EnableAnalytics(false)) }, + onClick = onTermsDeclined, modifier = Modifier.fillMaxWidth(), ) { Text(text = stringResource(id = CommonStrings.action_not_now)) diff --git a/features/ftue/api/build.gradle.kts b/features/ftue/api/build.gradle.kts new file mode 100644 index 0000000000..9fd36026b9 --- /dev/null +++ b/features/ftue/api/build.gradle.kts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.ftue.api" +} + +dependencies { + implementation(projects.libraries.architecture) +} diff --git a/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/FtueEntryPoint.kt b/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/FtueEntryPoint.kt new file mode 100644 index 0000000000..649a327f6e --- /dev/null +++ b/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/FtueEntryPoint.kt @@ -0,0 +1,36 @@ +/* + * 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.ftue.api + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import io.element.android.libraries.architecture.FeatureEntryPoint + +interface FtueEntryPoint : FeatureEntryPoint { + + fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder + + interface NodeBuilder { + fun callback(callback: Callback): NodeBuilder + fun build(): Node + } + + interface Callback : Plugin { + fun onFtueFlowFinished() + } +} diff --git a/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/state/FtueState.kt b/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/state/FtueState.kt new file mode 100644 index 0000000000..2c19d4e3a7 --- /dev/null +++ b/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/state/FtueState.kt @@ -0,0 +1,23 @@ +/* + * 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.ftue.api.state + +import kotlinx.coroutines.flow.StateFlow + +interface FtueState { + val shouldDisplayFlow: StateFlow +} diff --git a/features/ftue/impl/build.gradle.kts b/features/ftue/impl/build.gradle.kts new file mode 100644 index 0000000000..0dee792464 --- /dev/null +++ b/features/ftue/impl/build.gradle.kts @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) + alias(libs.plugins.ksp) + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.ftue.impl" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(projects.anvilannotations) + anvil(projects.anvilcodegen) + api(projects.features.ftue.api) + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.testtags) + implementation(projects.features.analytics.api) + implementation(projects.services.analytics.api) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.features.analytics.test) + + ksp(libs.showkase.processor) +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/DefaultFtueEntryPoint.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/DefaultFtueEntryPoint.kt new file mode 100644 index 0000000000..9c2f74f072 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/DefaultFtueEntryPoint.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.ftue.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.ftue.api.FtueEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.AppScope +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultFtueEntryPoint @Inject constructor() : FtueEntryPoint { + + override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): FtueEntryPoint.NodeBuilder { + val plugins = ArrayList() + + return object : FtueEntryPoint.NodeBuilder { + + override fun callback(callback: FtueEntryPoint.Callback): FtueEntryPoint.NodeBuilder { + plugins += callback + return this + } + + override fun build(): Node { + return parentNode.createNode(buildContext, plugins) + } + } + } +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt new file mode 100644 index 0000000000..0ff9c80d46 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt @@ -0,0 +1,154 @@ +/* + * 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.ftue.impl + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope +import com.bumble.appyx.core.composable.Children +import com.bumble.appyx.core.lifecycle.subscribe +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.navigation.backpresshandlerstrategies.BaseBackPressHandlerStrategy +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.newRoot +import com.bumble.appyx.navmodel.backstack.operation.replace +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.analytics.api.AnalyticsEntryPoint +import io.element.android.features.ftue.api.FtueEntryPoint +import io.element.android.features.ftue.impl.state.DefaultFtueState +import io.element.android.features.ftue.impl.state.FtueStep +import io.element.android.features.ftue.impl.welcome.WelcomeNode +import io.element.android.libraries.architecture.BackstackNode +import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.AppScope +import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize + +@ContributesNode(AppScope::class) +class FtueFlowNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val ftueState: DefaultFtueState, + private val analyticsEntryPoint: AnalyticsEntryPoint, + private val analyticsService: AnalyticsService, +) : BackstackNode( + backstack = BackStack( + initialElement = NavTarget.Placeholder, + savedStateMap = buildContext.savedStateMap, + backPressHandler = NoOpBackstackHandlerStrategy(), + ), + buildContext = buildContext, + plugins = plugins, +) { + + sealed interface NavTarget : Parcelable { + @Parcelize + object Placeholder : NavTarget + + @Parcelize + object WelcomeScreen : NavTarget + + @Parcelize + object AnalyticsOptIn : NavTarget + } + + private val callback = plugins.filterIsInstance().firstOrNull() + + override fun onBuilt() { + super.onBuilt() + + lifecycle.subscribe(onCreate = { + lifecycleScope.launch { moveToNextStep() } + }) + + analyticsService.didAskUserConsent() + .drop(1) // We only care about consent passing from not asked to asked state + .onEach { didAskUserConsent -> + if (didAskUserConsent) { + lifecycleScope.launch { moveToNextStep() } + } + } + .launchIn(lifecycleScope) + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.Placeholder -> { + createNode(buildContext) + } + NavTarget.WelcomeScreen -> { + val callback = object : WelcomeNode.Callback { + override fun onContinueClicked() { + ftueState.setWelcomeScreenShown() + lifecycleScope.launch { moveToNextStep() } + } + } + createNode(buildContext, listOf(callback)) + } + NavTarget.AnalyticsOptIn -> { + analyticsEntryPoint.createNode(this, buildContext) + } + } + } + + private suspend fun moveToNextStep() { + when (ftueState.getNextStep()) { + is FtueStep.WelcomeScreen -> { + backstack.newRoot(NavTarget.WelcomeScreen) + } + is FtueStep.AnalyticsOptIn -> { + backstack.replace(NavTarget.AnalyticsOptIn) + } + null -> callback?.onFtueFlowFinished() + } + } + + @Composable + override fun View(modifier: Modifier) { + Children( + navModel = backstack, + modifier = modifier, + transitionHandler = rememberDefaultTransitionHandler(), + ) + } + + @ContributesNode(AppScope::class) + class PlaceholderNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + ) : Node(buildContext, plugins = plugins) +} + +private class NoOpBackstackHandlerStrategy : BaseBackPressHandlerStrategy() { + override val canHandleBackPressFlow: StateFlow = MutableStateFlow(true) + + override fun onBackPressed() { + // No-op + } +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueState.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueState.kt new file mode 100644 index 0000000000..39b100808f --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueState.kt @@ -0,0 +1,89 @@ +/* + * 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.ftue.impl.state + +import androidx.annotation.VisibleForTesting +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.ftue.api.state.FtueState +import io.element.android.features.ftue.impl.welcome.state.WelcomeScreenState +import io.element.android.libraries.di.AppScope +import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.runBlocking +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultFtueState @Inject constructor( + private val coroutineScope: CoroutineScope, + private val analyticsService: AnalyticsService, + private val welcomeScreenState: WelcomeScreenState, +) : FtueState { + + override val shouldDisplayFlow = MutableStateFlow(isAnyStepIncomplete()) + + init { + analyticsService.didAskUserConsent() + .onEach { updateState() } + .launchIn(coroutineScope) + } + + fun getNextStep(currentStep: FtueStep? = null): FtueStep? = + when (currentStep) { + null -> if (shouldDisplayWelcomeScreen()) FtueStep.WelcomeScreen else getNextStep( + FtueStep.WelcomeScreen + ) + FtueStep.WelcomeScreen -> if (needsAnalyticsOptIn()) FtueStep.AnalyticsOptIn else getNextStep( + FtueStep.AnalyticsOptIn + ) + FtueStep.AnalyticsOptIn -> null + } + + private fun isAnyStepIncomplete(): Boolean { + return listOf( + shouldDisplayWelcomeScreen(), + needsAnalyticsOptIn() + ).any { it } + } + + private fun needsAnalyticsOptIn(): Boolean { + // We need this function to not be suspend, so we need to load the value through runBlocking + return runBlocking { analyticsService.didAskUserConsent().first().not() } + } + + private fun shouldDisplayWelcomeScreen(): Boolean { + return welcomeScreenState.isWelcomeScreenNeeded() + } + + fun setWelcomeScreenShown() { + welcomeScreenState.setWelcomeScreenShown() + updateState() + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun updateState() { + shouldDisplayFlow.value = isAnyStepIncomplete() + } +} + +sealed interface FtueStep { + object WelcomeScreen : FtueStep + object AnalyticsOptIn : FtueStep +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeNode.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeNode.kt new file mode 100644 index 0000000000..f4e0d9f640 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeNode.kt @@ -0,0 +1,54 @@ +/* + * 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.ftue.impl.welcome + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.di.AppScope + +@ContributesNode(AppScope::class) +class WelcomeNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val buildMeta: BuildMeta, +) : Node(buildContext, plugins = plugins) { + + interface Callback : Plugin { + fun onContinueClicked() + } + + private fun onContinueClicked() { + plugins.filterIsInstance().forEach { it.onContinueClicked() } + } + + @Composable + override fun View(modifier: Modifier) { + WelcomeView( + applicationName = buildMeta.applicationName, + onContinueClicked = ::onContinueClicked, + modifier = modifier + ) + } + +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeView.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeView.kt new file mode 100644 index 0000000000..ccb55494b8 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeView.kt @@ -0,0 +1,128 @@ +/* + * 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.ftue.impl.welcome + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AddComment +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.NewReleases +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.element.android.features.ftue.impl.R +import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtom +import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtomSize +import io.element.android.libraries.designsystem.atomic.molecules.InfoListItem +import io.element.android.libraries.designsystem.atomic.molecules.InfoListOrganism +import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage +import io.element.android.libraries.designsystem.preview.DayNightPreviews +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.testtags.testTag +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun WelcomeView( + applicationName: String, + modifier: Modifier = Modifier, + onContinueClicked: () -> Unit, +) { + BackHandler(onBack = onContinueClicked) + OnBoardingPage( + modifier = modifier + .systemBarsPadding() + .fillMaxSize(), + content = { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(78.dp)) + ElementLogoAtom(size = ElementLogoAtomSize.Medium) + Spacer(modifier = Modifier.height(32.dp)) + Text( + modifier = Modifier.testTag(TestTags.welcomeScreenTitle), + text = stringResource(R.string.screen_welcome_title, applicationName), + style = ElementTheme.typography.fontHeadingLgBold, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.screen_welcome_subtitle), + style = ElementTheme.typography.fontBodyMdRegular, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(40.dp)) + InfoListOrganism( + items = listItems(), + textStyle = ElementTheme.typography.fontBodyMdMedium, + iconTint = ElementTheme.colors.iconSecondary, + backgroundColor = ElementTheme.colors.bgCanvasDefault.copy(alpha = 0.7f), + ) + Spacer(modifier = Modifier.height(32.dp)) + } + }, + footer = { + Button(modifier = Modifier.fillMaxWidth(), onClick = onContinueClicked) { + Text(text = stringResource(CommonStrings.action_continue)) + } + Spacer(modifier = Modifier.height(32.dp)) + } + ) +} + +@Composable +private fun listItems() = persistentListOf( + InfoListItem( + message = stringResource(R.string.screen_welcome_bullet_1), + iconVector = Icons.Outlined.NewReleases, + ), + InfoListItem( + message = stringResource(R.string.screen_welcome_bullet_2), + iconVector = Icons.Outlined.Lock, + ), + InfoListItem( + message = stringResource(R.string.screen_welcome_bullet_3), + iconVector = Icons.Outlined.AddComment, + ), +) + +@DayNightPreviews +@Composable +internal fun WelcomeViewPreview() { + ElementPreview { + WelcomeView(applicationName = "Element X", onContinueClicked = {}) + } +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/AndroidWelcomeScreenState.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/AndroidWelcomeScreenState.kt new file mode 100644 index 0000000000..c482b4e744 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/AndroidWelcomeScreenState.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.ftue.impl.welcome.state + +import android.content.SharedPreferences +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.DefaultPreferences +import io.element.android.libraries.di.SingleIn +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) +class AndroidWelcomeScreenState @Inject constructor( + @DefaultPreferences private val sharedPreferences: SharedPreferences, +): WelcomeScreenState { + + companion object { + private const val IS_WELCOME_SCREEN_SHOWN = "is_welcome_screen_shown" + } + + override fun isWelcomeScreenNeeded(): Boolean { + return sharedPreferences.getBoolean(IS_WELCOME_SCREEN_SHOWN, false).not() + } + + override fun setWelcomeScreenShown() { + sharedPreferences.edit().putBoolean(IS_WELCOME_SCREEN_SHOWN, true).apply() + } +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/WelcomeScreenState.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/WelcomeScreenState.kt new file mode 100644 index 0000000000..0e5f79d7c1 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/WelcomeScreenState.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.ftue.impl.welcome.state + +interface WelcomeScreenState { + fun isWelcomeScreenNeeded(): Boolean + fun setWelcomeScreenShown() +} diff --git a/features/ftue/impl/src/main/res/values/localazy.xml b/features/ftue/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..17999e7158 --- /dev/null +++ b/features/ftue/impl/src/main/res/values/localazy.xml @@ -0,0 +1,9 @@ + + + "Calls, location sharing, search and more will be added later this year." + "Message history for encrypted rooms won’t be available in this update." + "We’d love to hear from you, let us know what you think via the settings page." + "Let\'s go!" + "Here’s what you need to know:" + "Welcome to %1$s!" + diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueStateTests.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueStateTests.kt new file mode 100644 index 0000000000..ce1683e8e5 --- /dev/null +++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueStateTests.kt @@ -0,0 +1,115 @@ +/* + * 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.ftue.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.analytics.test.FakeAnalyticsService +import io.element.android.features.ftue.impl.state.DefaultFtueState +import io.element.android.features.ftue.impl.state.FtueStep +import io.element.android.features.ftue.impl.welcome.state.FakeWelcomeState +import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultFtueStateTests { + + @Test + fun `given any check being false, should display flow is true`() = runTest { + val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob()) + val state = createState(coroutineScope) + + assertThat(state.shouldDisplayFlow.value).isTrue() + + // Cleanup + coroutineScope.cancel() + } + + @Test + fun `given all checks being true, should display flow is false`() = runTest { + val welcomeState = FakeWelcomeState() + val analyticsService = FakeAnalyticsService() + val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob()) + + val state = createState(coroutineScope, welcomeState, analyticsService) + + welcomeState.setWelcomeScreenShown() + analyticsService.setDidAskUserConsent() + state.updateState() + + assertThat(state.shouldDisplayFlow.value).isFalse() + + // Cleanup + coroutineScope.cancel() + } + + @Test + fun `traverse flow`() = runTest { + val welcomeState = FakeWelcomeState() + val analyticsService = FakeAnalyticsService() + val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob()) + + val state = createState(coroutineScope, welcomeState, analyticsService) + val steps = mutableListOf() + + // First step, welcome screen + steps.add(state.getNextStep(steps.lastOrNull())) + welcomeState.setWelcomeScreenShown() + + // Second step, analytics opt in + steps.add(state.getNextStep(steps.lastOrNull())) + analyticsService.setDidAskUserConsent() + + // Final step (null) + steps.add(state.getNextStep(steps.lastOrNull())) + + assertThat(steps).containsExactly( + FtueStep.WelcomeScreen, + FtueStep.AnalyticsOptIn, + null, // Final state + ) + + // Cleanup + coroutineScope.cancel() + } + + @Test + fun `if a check for a step is true, start from the next one`() = runTest { + val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob()) + val analyticsService = FakeAnalyticsService() + val state = createState(coroutineScope = coroutineScope, analyticsService = analyticsService) + + state.setWelcomeScreenShown() + assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn) + + analyticsService.setDidAskUserConsent() + assertThat(state.getNextStep(FtueStep.WelcomeScreen)).isNull() + + // Cleanup + coroutineScope.cancel() + } + + private fun createState( + coroutineScope: CoroutineScope, + welcomeState: FakeWelcomeState = FakeWelcomeState(), + analyticsService: AnalyticsService = FakeAnalyticsService() + ) = DefaultFtueState(coroutineScope, analyticsService, welcomeState) + +} diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/FakeWelcomeState.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/FakeWelcomeState.kt new file mode 100644 index 0000000000..198d79115a --- /dev/null +++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/FakeWelcomeState.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.ftue.impl.welcome.state + +class FakeWelcomeState : WelcomeScreenState { + + private var isWelcomeScreenNeeded = true + + override fun isWelcomeScreenNeeded(): Boolean { + return isWelcomeScreenNeeded + } + + override fun setWelcomeScreenShown() { + isWelcomeScreenNeeded = false + } +} diff --git a/features/login/impl/src/main/res/drawable/onboarding_icon_light.png b/features/login/impl/src/main/res/drawable/onboarding_icon_light.png new file mode 100644 index 0000000000000000000000000000000000000000..ffd8631c4777248a3eff841ae579fa91a724bc07 GIT binary patch literal 44244 zcmV)VK(D`vP)%0DYo!WJ}dXl=U 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 >|Jg77D<$&zDPx9@Nya}3+L4)zccC46W5uz#nx z54Kf`2p_H~gIGWFK5T!sb6)<~)_(r^g!|OL!tT=hmit*GxB;6>p10E)q+6nyyxtj0 zQ5Es4p}Gt7uj;!=Gz(M~X-)kSQS}x6*Y}Oh#_s{Mk(i2^fHbolIqnnt4W`>Cb|l+w zEi%Q4IwAN1wGzg%j;w`dr0Z52r97_ODnLqkT)H)+HJ5l$zDhThvdf`Z8uVNC`wjOg zY(rVzb{_K@nCG+-u&AaF_)$p+G1D>-tP&bF1+%dw!a|~{ zD#dYCX6==34BLlXLce``zAc=e_R=}UTDBD5kpT@Ovozmcx4!)4 zFAvX0D3-j_$o8}L#2k-3W_DTm{TSSD>Y~1Ha={7)ySnt;mfoLCQdOtSd0g-|q z5_>S%g@86Yy~;uU*%dO7?OZxI+_H@bAsx#DiB@FH*4B~?CPX4It3#Gu0RVxtK#DRb zZNKt1_T?C6emmYIge`4bR%Bq{KrSPFCShPqinIgYkcKhwO55op;xdo;R*R~<;v zCc`FFN z05e)|W0N}O85pQE_@%HduoD?L*d`1rvpbllga7cl92=5BhYiVqyXtLkQ-31gi$qmB z%YXullxM#;z3ENPLY&x!eZwF7S~NX&5XZ5N!4D%8Hc^IKh4Zlw+c+1W`Qv^#FUN6C zTQ6#h=zZ~b%{A8yBlp>lZS2E(@!Yzk5YTf)usX}HxuUN?F$s;0y>S@O&O70r&Buy% z0n^DnW-uKnE}%yqZRk8sN^m)LKWA=OYNvD>lTg75n#bu;k0qE6axZR4a+FIS}Q zjE*694l)bQ2CWOm8>NvKl$Za?y1Y?gNUsP3lG#!HH_VbnR7@Uv`OPRW^W#6hQ-4&- z&GW-kiV&u>%E6BmYAGsdP&MmvuBVC!i8qT%dX;-}>R?9u=_hG!A&lBI2_&i%j0||g zxmm&-s9T&%&Yhyt@@K7}_+wvNXFr_yY{OScyykSl;52_BJc=AMwsC&`bP|vOZx070-tC$2P=n>v*m9ZuF8md!^$;*IV6B*hmC;IJhid1Kpq9h* zv^-xoe0ChIHyiInR1WPKz2_6!f%;`hEiHpI=Ay6w*SL>9gJC>e&Vqn2NIWWLz!J>G^#Ed z-C1OOmrRJV9d}&4lM*WF-5SJJo^4u}5^oh6>gdHRR+ezX8kobKLs**m3w_bCvuu

);pai>8T`>ci+P%#st&GLD%)U)$L0H7d-tX=E6{#Xjo3Pg zW@h)4w-@Ksb&f0VGrAwD7}vE34cnI`$J%~!&!#%+{i zx;&nD%^*V7oPseYs@Qht2h(DMGHd0wN^cGYW+uA57x1QM7%yIRRa zcV{W5ah14NU)ttJ9Ll3WgsSP5662Qbr4yEHET6AzW96kd+`N=tGwQf5-_wSwxIycY zR+D_TeN`#Cp_W}3^oo^u7YLOBp&>)%BauDDwhe~cpbgrf4LTY9-$EUC<}w`m4FCWD M07*qoM6N<$g3+ZiZ2$lO literal 0 HcmV?d00001 diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index ee7a57c70e..949be6ed85 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -64,6 +64,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.theme.components.FloatingActionButton @@ -72,6 +73,8 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.theme.ElementTheme import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.launch @Composable diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt index 820560c2a2..d6b1c06f54 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt @@ -18,10 +18,11 @@ package io.element.android.features.messages.impl.timeline.components import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import io.element.android.features.messages.impl.timeline.components.virtual.TimelineEncryptedHistoryBannerView import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemDaySeparatorView -import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel @Composable @@ -32,5 +33,7 @@ fun TimelineItemVirtualRow( when (virtual.model) { is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model, modifier) TimelineItemReadMarkerModel -> return + is TimelineItemEncryptedHistoryBannerVirtualModel -> TimelineEncryptedHistoryBannerView(modifier) } } + diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineEncryptedHistoryBannerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineEncryptedHistoryBannerView.kt new file mode 100644 index 0000000000..055e4bb876 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineEncryptedHistoryBannerView.kt @@ -0,0 +1,69 @@ +/* + * 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.virtual + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.features.messages.impl.R +import io.element.android.libraries.designsystem.preview.DayNightPreviews +import io.element.android.libraries.theme.ElementTheme + +@Composable +fun TimelineEncryptedHistoryBannerView(modifier: Modifier = Modifier) { + Row( + modifier = modifier + .padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 32.dp) + .clip(MaterialTheme.shapes.small) + .border(1.dp, ElementTheme.colors.borderInfoSubtle, MaterialTheme.shapes.small) + .background(ElementTheme.colors.bgInfoSubtle) + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = "Info", + tint = ElementTheme.colors.iconInfoPrimary + ) + Text( + text = stringResource(R.string.screen_room_encrypted_history_banner), + style = ElementTheme.typography.fontBodyMdMedium, + color = ElementTheme.colors.textInfoPrimary + ) + } +} + +@DayNightPreviews +@Composable +internal fun TimelineEncryptedHistoryBannerViewPreview() { + ElementTheme { + TimelineEncryptedHistoryBannerView() + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt index f607a0e034..aa9786c945 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt @@ -45,7 +45,6 @@ class TimelineItemsFactory @Inject constructor( private val virtualItemFactory: TimelineItemVirtualFactory, private val timelineItemGrouper: TimelineItemGrouper, ) { - private val timelineItems = MutableStateFlow(persistentListOf()) private val timelineItemsCache = arrayListOf() diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/virtual/TimelineItemVirtualFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/virtual/TimelineItemVirtualFactory.kt index cca1786bf8..6178b1dee7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/virtual/TimelineItemVirtualFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/virtual/TimelineItemVirtualFactory.kt @@ -17,6 +17,7 @@ package io.element.android.features.messages.impl.timeline.factories.virtual import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem @@ -30,8 +31,13 @@ class TimelineItemVirtualFactory @Inject constructor( fun create( virtualTimelineItem: MatrixTimelineItem.Virtual, ): TimelineItem.Virtual { + val id = if (virtualTimelineItem.virtual is VirtualTimelineItem.EncryptedHistoryBanner) { + "encrypted_history_banner" + } else { + virtualTimelineItem.uniqueId.toString() + } return TimelineItem.Virtual( - id = virtualTimelineItem.uniqueId.toString(), + id = id, model = virtualTimelineItem.computeModel() ) } @@ -40,6 +46,7 @@ class TimelineItemVirtualFactory @Inject constructor( return when (val inner = virtual) { is VirtualTimelineItem.DayDivider -> daySeparatorFactory.create(inner) is VirtualTimelineItem.ReadMarker -> TimelineItemReadMarkerModel + is VirtualTimelineItem.EncryptedHistoryBanner -> TimelineItemEncryptedHistoryBannerVirtualModel } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemEncryptedHistoryBannerVirtualModel.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemEncryptedHistoryBannerVirtualModel.kt new file mode 100644 index 0000000000..442aed5734 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemEncryptedHistoryBannerVirtualModel.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.impl.timeline.model.virtual + +object TimelineItemEncryptedHistoryBannerVirtualModel : TimelineItemVirtualModel { + override val type: String = "TimelineItemEncryptedHistoryBannerVirtualModel" +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/ElementLogoAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/ElementLogoAtom.kt new file mode 100644 index 0000000000..19a1798f74 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/ElementLogoAtom.kt @@ -0,0 +1,170 @@ +/* + * 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.designsystem.atomic.atoms + +import android.graphics.BlurMaskFilter +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.graphics.ClipOp +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.R +import io.element.android.libraries.designsystem.preview.DayNightPreviews +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.theme.ElementTheme + +@Composable +fun ElementLogoAtom( + size: ElementLogoAtomSize, + modifier: Modifier = Modifier, +) { + val outerSize = when (size) { + ElementLogoAtomSize.Large -> 158.dp + ElementLogoAtomSize.Medium -> 120.dp + } + val logoSize = when (size) { + ElementLogoAtomSize.Large -> 110.dp + ElementLogoAtomSize.Medium -> 83.5.dp + } + val cornerRadius = when(size) { + ElementLogoAtomSize.Large -> 44.dp + ElementLogoAtomSize.Medium -> 33.dp + } + val borderWidth = when (size) { + ElementLogoAtomSize.Large -> 1.dp + ElementLogoAtomSize.Medium -> 0.38.dp + } + val blur = if (isSystemInDarkTheme()) { + 160.dp + } else { + 24.dp + } + //box-shadow: 0px 6.075949668884277px 24.30379867553711px 0px #1B1D2280; + val shadowColor = if (isSystemInDarkTheme()) { + Color.Black.copy(alpha = 0.4f) + } else { + Color(0x401B1D22) + } + val backgroundColor = if (isSystemInDarkTheme()) Color.White.copy(alpha = 0.2f) else Color.White.copy(alpha = 0.4f) + val borderColor = if (isSystemInDarkTheme()) Color.White.copy(alpha = 0.8f) else Color.White.copy(alpha = 0.4f) + Box( + modifier = modifier + .size(outerSize) + .border(borderWidth, borderColor, RoundedCornerShape(cornerRadius)), + contentAlignment = Alignment.Center, + ) { + Box( + Modifier + .size(outerSize) + .shapeShadow( + color = shadowColor, + cornerRadius = cornerRadius, + blurRadius = 32.dp, + offsetY = 8.dp, + ) + ) + Box( + Modifier + .clip(RoundedCornerShape(cornerRadius)) + .size(outerSize) + .background(backgroundColor) + .blur(blur) + ) + Image( + modifier = Modifier.size(logoSize), + painter = painterResource(id = R.drawable.element_logo), + contentDescription = null + ) + } +} + +enum class ElementLogoAtomSize { + Medium, + Large +} + +@Composable +@DayNightPreviews +internal fun ElementLogoAtomPreview() { + ElementPreview { + Box( + Modifier + .size(170.dp) + .background(ElementTheme.colors.bgSubtlePrimary)) + ElementLogoAtom(ElementLogoAtomSize.Large) + } +} + +fun Modifier.shapeShadow( + color: Color = Color.Black, + cornerRadius: Dp = 0.dp, + offsetX: Dp = 0.dp, + offsetY: Dp = 0.dp, + blurRadius: Dp = 0.dp, +) = then( + drawBehind { + drawIntoCanvas { canvas -> + val path = Path().apply { + addRoundRect(RoundRect(Rect(Offset.Zero, size), CornerRadius(cornerRadius.toPx()))) + } + + clipPath(path, ClipOp.Difference) { + val paint = Paint() + val frameworkPaint = paint.asFrameworkPaint() + if (blurRadius != 0.dp) { + frameworkPaint.maskFilter = (BlurMaskFilter(blurRadius.toPx(), BlurMaskFilter.Blur.NORMAL)) + } + frameworkPaint.color = color.toArgb() + + val leftPixel = offsetX.toPx() + val topPixel = offsetY.toPx() + val rightPixel = size.width + topPixel + val bottomPixel = size.height + leftPixel + + canvas.drawRect( + left = leftPixel, + top = topPixel, + right = rightPixel, + bottom = bottomPixel, + paint = paint, + ) + } + } + } +) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/InfoListItemMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/InfoListItemMolecule.kt new file mode 100644 index 0000000000..6b20c96880 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/InfoListItemMolecule.kt @@ -0,0 +1,113 @@ +/* + * 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.designsystem.atomic.atoms + +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.DayNightPreviews +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text + +@Composable +fun InfoListItemMolecule( + message: @Composable () -> Unit, + position: InfoListItemPosition, + backgroundColor: Color, + modifier: Modifier = Modifier, + icon: @Composable () -> Unit = {}, +) { + val radius = 14.dp + val backgroundShape = remember(position) { + when (position) { + InfoListItemPosition.Single -> RoundedCornerShape(radius) + InfoListItemPosition.Top -> RoundedCornerShape(topStart = radius, topEnd = radius) + InfoListItemPosition.Middle -> RoundedCornerShape(0.dp) + InfoListItemPosition.Bottom -> RoundedCornerShape(bottomStart = radius, bottomEnd = radius) + } + } + Row( + modifier = modifier + .fillMaxWidth() + .background( + color = backgroundColor, + shape = backgroundShape, + ) + .padding(vertical = 12.dp, horizontal = 20.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + icon() + message() + } +} + +@DayNightPreviews +@Composable +fun InfoListItemMoleculePreview() { + ElementPreview { + val color = if (isSystemInDarkTheme()) Color.DarkGray else Color.LightGray + Column( + modifier = Modifier.padding(10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + InfoListItemMolecule( + message = { Text("A single item") }, + icon = { Icon(imageVector = Icons.Default.Info, contentDescription = null) }, + position = InfoListItemPosition.Single, + backgroundColor = color, + ) + InfoListItemMolecule( + message = { Text("A top item") }, + icon = { Icon(imageVector = Icons.Default.Info, contentDescription = null) }, + position = InfoListItemPosition.Top, + backgroundColor = color, + ) + InfoListItemMolecule( + message = { Text("A middle item") }, + icon = { Icon(imageVector = Icons.Default.Info, contentDescription = null) }, + position = InfoListItemPosition.Middle, + backgroundColor = color, + ) + InfoListItemMolecule( + message = { Text("A bottom item") }, + icon = { Icon(imageVector = Icons.Default.Info, contentDescription = null) }, + position = InfoListItemPosition.Bottom, + backgroundColor = color, + ) + } + } +} + +enum class InfoListItemPosition { + Top, + Middle, + Bottom, + Single, +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/InfoListOrganism.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/InfoListOrganism.kt new file mode 100644 index 0000000000..60c61d99fc --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/InfoListOrganism.kt @@ -0,0 +1,79 @@ +/* + * 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.designsystem.atomic.molecules + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.atomic.atoms.InfoListItemMolecule +import io.element.android.libraries.designsystem.atomic.atoms.InfoListItemPosition +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun InfoListOrganism( + items: ImmutableList, + backgroundColor: Color, + modifier: Modifier = Modifier, + iconTint: Color = LocalContentColor.current, + textStyle: TextStyle = LocalTextStyle.current, + verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(4.dp), +) { + Column( + modifier = modifier, + verticalArrangement = verticalArrangement, + ) { + for ((index, item) in items.withIndex()) { + val position = when { + items.size == 1 -> InfoListItemPosition.Single + index == 0 -> InfoListItemPosition.Top + index == items.size - 1 -> InfoListItemPosition.Bottom + else -> InfoListItemPosition.Middle + } + InfoListItemMolecule( + message = { Text(item.message, style = textStyle) }, + icon = { + if (item.iconId != null) { + Icon(resourceId = item.iconId, contentDescription = null, tint = iconTint) + } else if (item.iconVector != null) { + Icon(imageVector = item.iconVector, contentDescription = null, tint = iconTint) + } else { + item.iconComposable() + } + }, + position = position, + backgroundColor = backgroundColor, + ) + } + } +} + +data class InfoListItem( + val message: String, + @DrawableRes val iconId: Int? = null, + val iconVector: ImageVector? = null, + val iconComposable: @Composable () -> Unit = {}, +) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/OnBoardingPage.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/OnBoardingPage.kt index 511bed24b5..ec3ee92be8 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/OnBoardingPage.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/OnBoardingPage.kt @@ -41,12 +41,14 @@ import io.element.android.libraries.theme.ElementTheme * * Ref: https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=133-5427&t=5SHVppfYzjvkEywR-0 * @param modifier Classical modifier. + * @param contentAlignment horizontal alignment of the contents. * @param footer optional footer. * @param content main content. */ @Composable fun OnBoardingPage( modifier: Modifier = Modifier, + contentAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, footer: @Composable () -> Unit = {}, content: @Composable () -> Unit = {}, ) { @@ -78,6 +80,7 @@ fun OnBoardingPage( .weight(1f) .padding(horizontal = 24.dp) .fillMaxWidth(), + horizontalAlignment = contentAlignment, ) { content() } diff --git a/libraries/designsystem/src/main/res/drawable/element_logo.xml b/libraries/designsystem/src/main/res/drawable/element_logo.xml new file mode 100644 index 0000000000..0101c0d541 --- /dev/null +++ b/libraries/designsystem/src/main/res/drawable/element_logo.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index 67e9e622b7..747de5f554 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -31,9 +31,7 @@ import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.verification.SessionVerificationService -import kotlinx.coroutines.TimeoutCancellationException import java.io.Closeable -import kotlin.time.Duration interface MatrixClient : Closeable { val sessionId: SessionId diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/virtual/VirtualTimelineItem.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/virtual/VirtualTimelineItem.kt index ed761a3d43..11fd8b9c63 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/virtual/VirtualTimelineItem.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/virtual/VirtualTimelineItem.kt @@ -24,4 +24,5 @@ sealed interface VirtualTimelineItem { object ReadMarker : VirtualTimelineItem + object EncryptedHistoryBanner : VirtualTimelineItem } diff --git a/libraries/matrix/impl/build.gradle.kts b/libraries/matrix/impl/build.gradle.kts index e18d13c2b2..7786a3ee3f 100644 --- a/libraries/matrix/impl/build.gradle.kts +++ b/libraries/matrix/impl/build.gradle.kts @@ -41,4 +41,8 @@ dependencies { implementation("net.java.dev.jna:jna:5.13.0@aar") implementation(libs.androidx.datastore.preferences) implementation(libs.serialization.json) + + testImplementation(libs.test.junit) + testImplementation(libs.test.truth) + testImplementation(projects.libraries.matrix.test) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 8798e01f98..d7195ebf11 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -157,6 +157,7 @@ class RustMatrixClient constructor( coroutineDispatchers = dispatchers, systemClock = clock, roomContentForwarder = roomContentForwarder, + sessionData = sessionStore.getSession(sessionId.value)!!, ) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt index bdb87b298c..0600e96f3b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt @@ -45,6 +45,7 @@ import org.matrix.rustcomponents.sdk.ClientBuilder import org.matrix.rustcomponents.sdk.Session import org.matrix.rustcomponents.sdk.use import java.io.File +import java.util.Date import javax.inject.Inject import org.matrix.rustcomponents.sdk.AuthenticationService as RustAuthenticationService @@ -208,4 +209,5 @@ private fun Session.toSessionData() = SessionData( refreshToken = refreshToken, homeserverUrl = homeserverUrl, slidingSyncProxy = slidingSyncProxy, + loginTimestamp = Date(), ) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt index 9eced2d5cb..0b886af8ed 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt @@ -42,6 +42,8 @@ import io.element.android.libraries.matrix.impl.room.location.toInner import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline import io.element.android.libraries.matrix.impl.timeline.backPaginationStatusFlow import io.element.android.libraries.matrix.impl.timeline.timelineDiffFlow +import io.element.android.libraries.sessionstorage.api.SessionData +import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.services.toolbox.api.systemclock.SystemClock import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -73,6 +75,7 @@ class RustMatrixRoom( private val coroutineDispatchers: CoroutineDispatchers, private val systemClock: SystemClock, private val roomContentForwarder: RoomContentForwarder, + private val sessionData: SessionData, ) : MatrixRoom { override val roomId = RoomId(innerRoom.id()) @@ -91,7 +94,8 @@ class RustMatrixRoom( matrixRoom = this, innerRoom = innerRoom, roomCoroutineScope = roomCoroutineScope, - dispatcher = roomDispatcher + dispatcher = roomDispatcher, + lastLoginTimestamp = sessionData.loginTimestamp, ) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt index 2c245c7164..e213fb623c 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt @@ -21,19 +21,23 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.TimelineException +import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem import io.element.android.libraries.matrix.impl.timeline.item.event.EventMessageMapper import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTimelineItemMapper import kotlinx.coroutines.CompletableDeferred +import io.element.android.libraries.matrix.impl.timeline.postprocessor.TimelineEncryptedHistoryPostProcessor import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.sample import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.BackPaginationStatus @@ -43,6 +47,7 @@ import org.matrix.rustcomponents.sdk.TimelineDiff import org.matrix.rustcomponents.sdk.TimelineItem import timber.log.Timber import java.util.concurrent.atomic.AtomicBoolean +import java.util.Date private const val INITIAL_MAX_SIZE = 50 @@ -51,6 +56,7 @@ class RustMatrixTimeline( private val matrixRoom: MatrixRoom, private val innerRoom: Room, private val dispatcher: CoroutineDispatcher, + private val lastLoginTimestamp: Date?, ) : MatrixTimeline { private val initLatch = CompletableDeferred() @@ -63,6 +69,12 @@ class RustMatrixTimeline( MatrixTimeline.PaginationState(hasMoreToLoadBackwards = true, isBackPaginating = false) ) + private val encryptedHistoryPostProcessor = TimelineEncryptedHistoryPostProcessor( + lastLoginTimestamp = lastLoginTimestamp, + isRoomEncrypted = matrixRoom.isEncrypted, + paginationStateFlow = _paginationState, + ) + private val timelineItemFactory = MatrixTimelineItemMapper( fetchDetailsForEvent = this::fetchDetailsForEvent, roomCoroutineScope = roomCoroutineScope, @@ -81,8 +93,11 @@ class RustMatrixTimeline( override val paginationState: StateFlow = _paginationState.asStateFlow() - @OptIn(FlowPreview::class) + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) override val timelineItems: Flow> = _timelineItems.sample(50) + .mapLatest { items -> + encryptedHistoryPostProcessor.process(items) + } internal suspend fun postItems(items: List) { // Split the initial items in multiple list as there is no pagination in the cached data, so we can post timelineItems asap. @@ -100,6 +115,12 @@ class RustMatrixTimeline( internal fun postPaginationStatus(status: BackPaginationStatus) { _paginationState.getAndUpdate { currentPaginationState -> + if (hasEncryptionHistoryBanner()) { + return@getAndUpdate currentPaginationState.copy( + isBackPaginating = false, + hasMoreToLoadBackwards = false, + ) + } when (status) { BackPaginationStatus.IDLE -> { currentPaginationState.copy( @@ -159,4 +180,10 @@ class RustMatrixTimeline( fun getItemById(eventId: EventId): MatrixTimelineItem.Event? { return _timelineItems.value.firstOrNull { (it as? MatrixTimelineItem.Event)?.eventId == eventId } as? MatrixTimelineItem.Event } + + private fun hasEncryptionHistoryBanner(): Boolean { + val firstItem = _timelineItems.value.firstOrNull() + return firstItem is MatrixTimelineItem.Virtual + && firstItem.virtual is VirtualTimelineItem.EncryptedHistoryBanner + } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessor.kt new file mode 100644 index 0000000000..ca5c7342f8 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessor.kt @@ -0,0 +1,74 @@ +/* + * 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.timeline.postprocessor + +import io.element.android.libraries.matrix.api.timeline.MatrixTimeline +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.getAndUpdate +import java.util.Date +import java.util.UUID + +class TimelineEncryptedHistoryPostProcessor( + private val lastLoginTimestamp: Date?, + private val isRoomEncrypted: Boolean, + private val paginationStateFlow: MutableStateFlow, +) { + + fun process(items: List): List { + if (!isRoomEncrypted || lastLoginTimestamp == null) return items + + val filteredItems = replaceWithEncryptionHistoryBannerIfNeeded(items) + // Disable back pagination + val wasFiltered = filteredItems !== items + if (wasFiltered) { + paginationStateFlow.getAndUpdate { + it.copy( + isBackPaginating = false, + hasMoreToLoadBackwards = false + ) + } + } + return filteredItems + } + + private fun replaceWithEncryptionHistoryBannerIfNeeded(list: List): List { + var lastEncryptedHistoryBannerIndex = -1 + for ((i, item) in list.withIndex()) { + if (isItemEncryptionHistory(item)) { + lastEncryptedHistoryBannerIndex = i + } + } + return if (lastEncryptedHistoryBannerIndex >= 0) { + val sublist = list.drop(lastEncryptedHistoryBannerIndex + 1).toMutableList() + sublist.add(0, MatrixTimelineItem.Virtual(0L, VirtualTimelineItem.EncryptedHistoryBanner)) + sublist + } else { + list + } + } + + private fun isItemEncryptionHistory(item: MatrixTimelineItem): Boolean { + if ((item as? MatrixTimelineItem.Virtual)?.virtual is VirtualTimelineItem.EncryptedHistoryBanner) { + return true + } + val timestamp = (item as? MatrixTimelineItem.Event)?.event?.timestamp ?: return false + return timestamp <= lastLoginTimestamp!!.time + } + +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessorTest.kt new file mode 100644 index 0000000000..91f0bc1883 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessorTest.kt @@ -0,0 +1,115 @@ +/* + * 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.timeline.postprocessor + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.timeline.MatrixTimeline +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem +import io.element.android.libraries.matrix.test.room.anEventTimelineItem +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Test +import java.util.Date + +class TimelineEncryptedHistoryPostProcessorTest { + + private val defaultLastLoginTimestamp = Date(1689061264L) + + @Test + fun `given an unencrypted room, nothing is done`() { + val processor = createPostProcessor(isRoomEncrypted = false) + val items = listOf( + MatrixTimelineItem.Event(0L, anEventTimelineItem()) + ) + assertThat(processor.process(items)).isSameInstanceAs(items) + } + + @Test + fun `given a null lastLoginTimestamp, nothing is done`() { + val processor = createPostProcessor(lastLoginTimestamp = null) + val items = listOf( + MatrixTimelineItem.Event(0L, anEventTimelineItem()) + ) + assertThat(processor.process(items)).isSameInstanceAs(items) + } + + @Test + fun `given an empty list, nothing is done`() { + val processor = createPostProcessor() + val items = emptyList() + assertThat(processor.process(items)).isSameInstanceAs(items) + } + + @Test + fun `given a list with no items before lastLoginTimestamp, nothing is done`() { + val processor = createPostProcessor() + val items = listOf( + MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time + 1)) + ) + assertThat(processor.process(items)).isSameInstanceAs(items) + } + + @Test + fun `given a list with an item with equal timestamp as lastLoginTimestamp, it's replaced`() { + val processor = createPostProcessor() + val items = listOf( + MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time)) + ) + assertThat(processor.process(items)) + .isEqualTo(listOf(MatrixTimelineItem.Virtual(0L, VirtualTimelineItem.EncryptedHistoryBanner))) + } + + @Test + fun `given a list with an item with a lower timestamp than lastLoginTimestamp, it's replaced`() { + val processor = createPostProcessor() + val items = listOf( + MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time - 1)) + ) + assertThat(processor.process(items)).isEqualTo( + listOf(MatrixTimelineItem.Virtual(0L, VirtualTimelineItem.EncryptedHistoryBanner)) + ) + } + + @Test + fun `given a list with several with lower or equal timestamps than lastLoginTimestamp, they're replaced and the user can't back paginate`() { + val paginationStateFlow = MutableStateFlow(MatrixTimeline.PaginationState(hasMoreToLoadBackwards = true, isBackPaginating = false)) + val processor = createPostProcessor(paginationStateFlow = paginationStateFlow) + val items = listOf( + MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time - 1)), + MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time)), + MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time + 1)), + ) + assertThat(processor.process(items)).isEqualTo( + listOf( + MatrixTimelineItem.Virtual(0L, VirtualTimelineItem.EncryptedHistoryBanner), + MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time + 1)) + ) + ) + assertThat(paginationStateFlow.value).isEqualTo(MatrixTimeline.PaginationState(hasMoreToLoadBackwards = false, isBackPaginating = false)) + } + + private fun createPostProcessor( + lastLoginTimestamp: Date? = defaultLastLoginTimestamp, + isRoomEncrypted: Boolean = true, + paginationStateFlow: MutableStateFlow = + MutableStateFlow(MatrixTimeline.PaginationState(hasMoreToLoadBackwards = true, isBackPaginating = false)) + ) = TimelineEncryptedHistoryPostProcessor( + lastLoginTimestamp = lastLoginTimestamp, + isRoomEncrypted = isRoomEncrypted, + paginationStateFlow = paginationStateFlow, + ) +} diff --git a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt index f2eb5847f7..cc106f960a 100644 --- a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt @@ -16,11 +16,14 @@ package io.element.android.libraries.sessionstorage.api +import java.util.Date + data class SessionData( val userId: String, val deviceId: String, val accessToken: String, val refreshToken: String?, val homeserverUrl: String, - val slidingSyncProxy: String? + val slidingSyncProxy: String?, + val loginTimestamp: Date?, ) diff --git a/libraries/session-storage/impl/build.gradle.kts b/libraries/session-storage/impl/build.gradle.kts index cd42a18402..698bfcf230 100644 --- a/libraries/session-storage/impl/build.gradle.kts +++ b/libraries/session-storage/impl/build.gradle.kts @@ -48,5 +48,7 @@ dependencies { } sqldelight { - database("SessionDatabase") {} + database("SessionDatabase") { + verifyMigrations = true + } } diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/SessionDataMapper.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/SessionDataMapper.kt index fd8a42ad6f..dbb42a8451 100644 --- a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/SessionDataMapper.kt +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/SessionDataMapper.kt @@ -17,19 +17,22 @@ package io.element.android.libraries.sessionstorage.impl import io.element.android.libraries.sessionstorage.api.SessionData +import java.util.Date +import io.element.android.libraries.matrix.session.SessionData as DbSessionData -internal fun SessionData.toDbModel(): io.element.android.libraries.matrix.session.SessionData { - return io.element.android.libraries.matrix.session.SessionData( +internal fun SessionData.toDbModel(): DbSessionData { + return DbSessionData( userId = userId, deviceId = deviceId, accessToken = accessToken, refreshToken = refreshToken, homeserverUrl = homeserverUrl, slidingSyncProxy = slidingSyncProxy, + loginTimestamp = loginTimestamp?.time, ) } -internal fun io.element.android.libraries.matrix.session.SessionData.toApiModel(): SessionData { +internal fun DbSessionData.toApiModel(): SessionData { return SessionData( userId = userId, deviceId = deviceId, @@ -37,5 +40,6 @@ internal fun io.element.android.libraries.matrix.session.SessionData.toApiModel( refreshToken = refreshToken, homeserverUrl = homeserverUrl, slidingSyncProxy = slidingSyncProxy, + loginTimestamp = loginTimestamp?.let { Date(it) } ) } diff --git a/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq b/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq index ea8471a36a..c3123f2ffb 100644 --- a/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq +++ b/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq @@ -4,9 +4,11 @@ CREATE TABLE SessionData ( accessToken TEXT NOT NULL, refreshToken TEXT, homeserverUrl TEXT NOT NULL, - slidingSyncProxy TEXT + slidingSyncProxy TEXT, + loginTimestamp INTEGER ); + selectFirst: SELECT * FROM SessionData LIMIT 1; @@ -17,7 +19,7 @@ selectByUserId: SELECT * FROM SessionData WHERE userId = ?; insertSessionData: -INSERT INTO SessionData(userId, deviceId, accessToken, refreshToken, homeserverUrl, slidingSyncProxy) VALUES ?; +INSERT INTO SessionData VALUES ?; removeSession: DELETE FROM SessionData WHERE userId = ?; diff --git a/libraries/session-storage/impl/src/main/sqldelight/migrations/0.sqm b/libraries/session-storage/impl/src/main/sqldelight/migrations/0.sqm new file mode 100644 index 0000000000..396a8f28dd --- /dev/null +++ b/libraries/session-storage/impl/src/main/sqldelight/migrations/0.sqm @@ -0,0 +1,8 @@ +CREATE TABLE SessionData ( + userId TEXT NOT NULL PRIMARY KEY, + deviceId TEXT NOT NULL, + accessToken TEXT NOT NULL, + refreshToken TEXT, + homeserverUrl TEXT NOT NULL, + slidingSyncProxy TEXT +); diff --git a/libraries/session-storage/impl/src/main/sqldelight/migrations/1.sqm b/libraries/session-storage/impl/src/main/sqldelight/migrations/1.sqm new file mode 100644 index 0000000000..3ee7762585 --- /dev/null +++ b/libraries/session-storage/impl/src/main/sqldelight/migrations/1.sqm @@ -0,0 +1 @@ +ALTER TABLE SessionData ADD COLUMN loginTimestamp INTEGER; diff --git a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt index 28b9dfba50..fc24c5a011 100644 --- a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt +++ b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt @@ -35,7 +35,8 @@ class DatabaseSessionStoreTests { accessToken = "accessToken", refreshToken = "refreshToken", homeserverUrl = "homeserverUrl", - slidingSyncProxy = null + slidingSyncProxy = null, + loginTimestamp = null, ) @Before diff --git a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt index d702c797a8..d832a6168d 100644 --- a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt +++ b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt @@ -42,6 +42,11 @@ object TestTags { * Room list / Home screen. */ val homeScreenSettings = TestTag("home_screen-settings") + + /** + * Welcome screen. + */ + val welcomeScreenTitle = TestTag("welcome_screen-title") } diff --git a/libraries/ui-strings/src/main/res/values-de/translations.xml b/libraries/ui-strings/src/main/res/values-de/translations.xml index 768097f37c..10694181da 100644 --- a/libraries/ui-strings/src/main/res/values-de/translations.xml +++ b/libraries/ui-strings/src/main/res/values-de/translations.xml @@ -176,12 +176,6 @@ "In OpenStreetMap öffnen" "Diesen Ort teilen" "Standort" - "Anrufe, Standortfreigabe, Suche und mehr werden später in diesem Jahr hinzugefügt." - "Der Nachrichtenverlauf für verschlüsselte Räume wird in diesem Update nicht verfügbar sein." - "Wir würden uns freuen, wenn du uns über die Einstellungsseite deine Meinung mitteilst." - "Los geht\'s!" - "Folgendes musst du wissen:" - "Willkommen bei %1$s!" "Rageshake" "Erkennungsschwelle" "Allgemein" diff --git a/libraries/ui-strings/src/main/res/values-fr/translations.xml b/libraries/ui-strings/src/main/res/values-fr/translations.xml index 2ff0ae3914..e3fe11dfdc 100644 --- a/libraries/ui-strings/src/main/res/values-fr/translations.xml +++ b/libraries/ui-strings/src/main/res/values-fr/translations.xml @@ -178,11 +178,6 @@ "Ouvrir dans Google Maps" "Ouvrir dans OpenStreetMap" "Partager cette position" - "L’historique des messages pour les salons chiffrés ne sera pas disponible dans cette mise à jour." - "Nous serions ravis d’avoir votre avis, n’hésitez pas à nous le partager via la page des paramètres." - "C’est parti !" - "Voici ce qu’il faut savoir :" - "Bienvenue sur %1$s !" "Rageshake" "Seuil de détection" "Général" diff --git a/libraries/ui-strings/src/main/res/values-sk/translations.xml b/libraries/ui-strings/src/main/res/values-sk/translations.xml index d75afca6a4..336ae9144c 100644 --- a/libraries/ui-strings/src/main/res/values-sk/translations.xml +++ b/libraries/ui-strings/src/main/res/values-sk/translations.xml @@ -183,12 +183,6 @@ "Otvoriť v OpenStreetMap" "Zdieľajte túto polohu" "Poloha" - "Hovory, zdieľanie polohy, vyhľadávanie a ďalšie funkcie pribudnú neskôr v tomto roku." - "História správ pre zašifrované miestnosti nebude v tejto aktualizácii k dispozícii." - "Radi by sme od vás počuli, dajte nám vedieť, čo si myslíte, prostredníctvom stránky nastavení." - "Poďme na to!" - "Tu je to, čo potrebujete vedieť:" - "Vitajte v %1$s!" "Zúrivé potrasenie" "Prahová hodnota detekcie" "Všeobecné" diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 94dceaaa7e..c73284e700 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -182,12 +182,6 @@ "Open in OpenStreetMap" "Share this location" "Location" - "Calls, location sharing, search and more will be added later this year." - "Message history for encrypted rooms won’t be available in this update." - "We’d love to hear from you, let us know what you think via the settings page." - "Let\'s go!" - "Here’s what you need to know:" - "Welcome to %1$s!" "Rageshake" "Detection threshold" "General" diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.ftue.impl.welcome_null_DefaultGroup_WelcomeViewPreview-D-0_1_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.ftue.impl.welcome_null_DefaultGroup_WelcomeViewPreview-D-0_1_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d0e4757049 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.ftue.impl.welcome_null_DefaultGroup_WelcomeViewPreview-D-0_1_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:76a68f2fc93894d6f9d9caea02546766c55664c0e53ba9506c6c32df058f5823 +size 303608 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.ftue.impl.welcome_null_DefaultGroup_WelcomeViewPreview-N-0_2_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.ftue.impl.welcome_null_DefaultGroup_WelcomeViewPreview-N-0_2_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..68ee3f3801 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.ftue.impl.welcome_null_DefaultGroup_WelcomeViewPreview-N-0_2_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f24bb3e40dd8c02037bd9d4523726ec0a0b1a283d23a8ca143973b0e9ee673c6 +size 408318 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineEncryptedHistoryBannerViewPreview-D-7_8_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineEncryptedHistoryBannerViewPreview-D-7_8_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..45593d6af2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineEncryptedHistoryBannerViewPreview-D-7_8_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2c806ee0293d94ab1c882b0f0ceaac2c7ac69d587f47cc22b4d16bc331351a2e +size 14711 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineEncryptedHistoryBannerViewPreview-N-7_9_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineEncryptedHistoryBannerViewPreview-N-7_9_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c74bbe95f8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineEncryptedHistoryBannerViewPreview-N-7_9_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:77c97c6c99943858530219234356459a7f0a88774332a4f11cd738b92ee15c91 +size 14200 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_ElementLogoAtomPreview-D_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_ElementLogoAtomPreview-D_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d903b752b0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_ElementLogoAtomPreview-D_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2947531c19a0ac9a7e35c3f2a394f6eb805427e1ad296d22b7d8b5cbb2428e07 +size 20947 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_ElementLogoAtomPreview-N_1_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_ElementLogoAtomPreview-N_1_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..bd9fca6c0f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_ElementLogoAtomPreview-N_1_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6f88eb992060d5b41ce3200bdc48d4fe6accaeda857d1ca08cb65ed8235798f7 +size 20266 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_InfoListItemMoleculePreview-D_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_InfoListItemMoleculePreview-D_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..15308b30bc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_InfoListItemMoleculePreview-D_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:51616fee6314d06981ce18d654c166d8e941be3264578c89e479a2a1267caa65 +size 19226 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_InfoListItemMoleculePreview-N_1_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_InfoListItemMoleculePreview-N_1_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b3af060ee1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_InfoListItemMoleculePreview-N_1_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6a7f455414ed06ec16785049bc3e99fa312a89599d24bcda0dc611c390e10c73 +size 18734 diff --git a/tools/localazy/config.json b/tools/localazy/config.json index 3feadf7a7a..fce6b317b5 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -113,6 +113,12 @@ "includeRegex": [ "screen_analytics_prompt.*" ] + }, + { + "name": ":features:ftue:impl", + "includeRegex": [ + "screen_welcome_.*" + ] } ] } From 9846db63385636860f3fe197607ceb1de03f95f1 Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 17 Jul 2023 18:34:50 +0200 Subject: [PATCH 27/42] Rework a bit MatrixClientHolder and reintroduce cacheIndex... --- .../io/element/android/appnav/RootFlowNode.kt | 66 ++++------- .../android/appnav/di/MatrixClientsHolder.kt | 83 ++++++++++++++ .../android/appnav/root/RootNavState.kt | 22 ++++ .../appnav/root/RootNavStateFlowFactory.kt | 90 +++++++++++++++ .../impl/tasks/ClearCacheUseCase.kt | 1 - .../matrix/api/MatrixClientProvider.kt | 28 +++++ .../matrix/ui/di/MatrixClientsHolder.kt | 106 ------------------ .../DefaultNotificationDrawerManager.kt | 9 +- .../notifications/NotifiableEventResolver.kt | 22 ++-- 9 files changed, 255 insertions(+), 172 deletions(-) create mode 100644 appnav/src/main/kotlin/io/element/android/appnav/root/RootNavState.kt create mode 100644 appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClientProvider.kt delete mode 100644 libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/di/MatrixClientsHolder.kt diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index adc1c3384d..089e956c61 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -37,15 +37,14 @@ import com.bumble.appyx.navmodel.backstack.operation.push import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode -import io.element.android.libraries.matrix.ui.di.MatrixClientsHolder +import io.element.android.appnav.di.MatrixClientsHolder import io.element.android.appnav.intent.IntentResolver import io.element.android.appnav.intent.ResolvedIntent +import io.element.android.appnav.root.RootNavStateFlowFactory import io.element.android.appnav.root.RootPresenter import io.element.android.appnav.root.RootView -import io.element.android.features.login.api.LoginUserStory import io.element.android.features.login.api.oidc.OidcAction import io.element.android.features.login.api.oidc.OidcActionFlow -import io.element.android.features.preferences.api.CacheService import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler @@ -57,29 +56,22 @@ import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.core.SessionId import kotlinx.coroutines.flow.distinctUntilChanged - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.onStart import kotlinx.parcelize.Parcelize import timber.log.Timber -import java.util.UUID @ContributesNode(AppScope::class) class RootFlowNode @AssistedInject constructor( @Assisted val buildContext: BuildContext, @Assisted plugins: List, private val authenticationService: MatrixAuthenticationService, - private val cacheService: CacheService, + private val navStateFlowFactory: RootNavStateFlowFactory, private val matrixClientsHolder: MatrixClientsHolder, private val presenter: RootPresenter, private val bugReportEntryPoint: BugReportEntryPoint, private val intentResolver: IntentResolver, private val oidcActionFlow: OidcActionFlow, - private val loginUserStory: LoginUserStory, ) : BackstackNode( backstack = BackStack( @@ -91,26 +83,25 @@ class RootFlowNode @AssistedInject constructor( ) { override fun onBuilt() { - matrixClientsHolder.restore(buildContext.savedStateMap) + matrixClientsHolder.restoreWithSavedState(buildContext.savedStateMap) super.onBuilt() - observeLoggedInState() + observeNavState() } override fun onSaveInstanceState(state: MutableSavedStateMap) { super.onSaveInstanceState(state) - matrixClientsHolder.save(state) + matrixClientsHolder.saveIntoSavedState(state) + navStateFlowFactory.saveIntoSavedState(state) } - private fun observeLoggedInState() { - combine( - cacheService.onClearedCacheEventFlow(), - isUserLoggedInFlow(), - ) { _, isLoggedIn -> isLoggedIn } - .onEach { isLoggedIn -> - Timber.v("isLoggedIn=$isLoggedIn") - if (isLoggedIn) { + private fun observeNavState() { + navStateFlowFactory.create(buildContext.savedStateMap) + .distinctUntilChanged() + .onEach { navState -> + Timber.v("navState=$navState") + if (navState.isLoggedIn) { tryToRestoreLatestSession( - onSuccess = { switchToLoggedInFlow(it) }, + onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) }, onFailure = { switchToNotLoggedInFlow() } ) } else { @@ -120,19 +111,8 @@ class RootFlowNode @AssistedInject constructor( .launchIn(lifecycleScope) } - - private fun switchToLoggedInFlow(sessionId: SessionId) { - backstack.safeRoot(NavTarget.LoggedInFlow(sessionId)) - } - - private fun isUserLoggedInFlow(): Flow { - return combine( - authenticationService.isLoggedIn(), - loginUserStory.loginFlowIsDone - ) { isLoggedIn, loginFlowIsDone -> - isLoggedIn && loginFlowIsDone - } - .distinctUntilChanged() + private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) { + backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, navId)) } private fun switchToNotLoggedInFlow() { @@ -145,9 +125,7 @@ class RootFlowNode @AssistedInject constructor( onFailure: () -> Unit = {}, onSuccess: (SessionId) -> Unit = {}, ) { - runCatching { - matrixClientsHolder.requireSession(sessionId) - } + matrixClientsHolder.getOrRestore(sessionId) .onSuccess { Timber.v("Succeed to restore session $sessionId") onSuccess(sessionId) @@ -200,7 +178,7 @@ class RootFlowNode @AssistedInject constructor( @Parcelize data class LoggedInFlow( val sessionId: SessionId, - val navId: UUID = UUID.randomUUID(), + val navId: Int ) : NavTarget @Parcelize @@ -274,11 +252,5 @@ class RootFlowNode @AssistedInject constructor( navTarget is NavTarget.LoggedInFlow && navTarget.sessionId == sessionId } } - - private fun CacheService.onClearedCacheEventFlow(): Flow { - return clearedCacheEventFlow - .onEach { sessionId -> matrixClientsHolder.remove(sessionId) } - .map { } - .onStart { emit((Unit)) } - } } + diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt index 53a49aef9f..ac0eb60763 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt @@ -16,3 +16,86 @@ package io.element.android.appnav.di +import com.bumble.appyx.core.state.MutableSavedStateMap +import com.bumble.appyx.core.state.SavedStateMap +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.MatrixClientProvider +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import timber.log.Timber +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject + +private const val SAVE_INSTANCE_KEY = "io.element.android.x.di.MatrixClientsHolder.SaveInstanceKey" + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class MatrixClientsHolder @Inject constructor(private val authenticationService: MatrixAuthenticationService): MatrixClientProvider { + + private val sessionIdsToMatrixClient = ConcurrentHashMap() + private val restoreMutex = Mutex() + + fun removeAll() { + sessionIdsToMatrixClient.clear() + } + + fun remove(sessionId: SessionId) { + sessionIdsToMatrixClient.remove(sessionId) + } + + fun isEmpty(): Boolean = sessionIdsToMatrixClient.isEmpty() + + fun getOrNull(sessionId: SessionId): MatrixClient? { + return sessionIdsToMatrixClient[sessionId] + } + + override suspend fun getOrRestore(sessionId: SessionId): Result { + return restoreMutex.withLock { + when (val matrixClient = sessionIdsToMatrixClient[sessionId]) { + null -> restore(sessionId) + else -> Result.success(matrixClient) + } + } + } + + @Suppress("UNCHECKED_CAST") + fun restoreWithSavedState(state: SavedStateMap?) { + Timber.d("Restore state") + if (state == null || sessionIdsToMatrixClient.isNotEmpty()) return Unit.also { + Timber.w("Restore with non-empty map") + } + val sessionIds = state[SAVE_INSTANCE_KEY] as? Array + Timber.d("Restore matrix session keys = ${sessionIds?.map { it.value }}") + if (sessionIds.isNullOrEmpty()) return + // Not ideal but should only happens in case of process recreation. This ensure we restore all the active sessions before restoring the node graphs. + runBlocking { + sessionIds.forEach { sessionId -> + restore(sessionId) + } + } + } + + fun saveIntoSavedState(state: MutableSavedStateMap) { + val sessionKeys = sessionIdsToMatrixClient.keys.toTypedArray() + Timber.d("Save matrix session keys = ${sessionKeys.map { it.value }}") + state[SAVE_INSTANCE_KEY] = sessionKeys + } + + private suspend fun restore(sessionId: SessionId): Result { + Timber.d("Restore matrix session: $sessionId") + return authenticationService.restoreSession(sessionId) + .onSuccess { matrixClient -> + sessionIdsToMatrixClient[matrixClient.sessionId] = matrixClient + } + .onFailure { + Timber.e("Fail to restore session") + } + } + +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavState.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavState.kt new file mode 100644 index 0000000000..55edf96aee --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavState.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.appnav.root + +data class RootNavState( + val cacheIndex: Int, + val isLoggedIn: Boolean +) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt new file mode 100644 index 0000000000..84f50a835e --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.appnav.root + +import com.bumble.appyx.core.state.MutableSavedStateMap +import com.bumble.appyx.core.state.SavedStateMap +import io.element.android.appnav.di.MatrixClientsHolder +import io.element.android.features.login.api.LoginUserStory +import io.element.android.features.preferences.api.CacheService +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject + +private const val SAVE_INSTANCE_KEY = "io.element.android.x.RootNavStateFlowFactory.SAVE_INSTANCE_KEY" + +class RootNavStateFlowFactory @Inject constructor( + private val authenticationService: MatrixAuthenticationService, + private val cacheService: CacheService, + private val matrixClientsHolder: MatrixClientsHolder, + private val loginUserStory: LoginUserStory, +) { + + private var currentCacheIndex = 0 + + fun create(savedStateMap: SavedStateMap?): Flow { + /** + * A flow of integer, where each time a clear cache is done, we have a new incremented value. + */ + val initialCacheIndex = savedStateMap.getCacheIndexOrDefault() + val cacheIndexFlow = cacheService.clearedCacheEventFlow + .onEach { sessionId -> + matrixClientsHolder.remove(sessionId) + } + .toIndexFlow(initialCacheIndex) + .onEach { cacheIndex -> + currentCacheIndex = cacheIndex + } + + return combine( + cacheIndexFlow, + isUserLoggedInFlow(), + ) { navId, isLoggedIn -> + RootNavState(navId, isLoggedIn) + } + } + + fun saveIntoSavedState(stateMap: MutableSavedStateMap) { + stateMap[SAVE_INSTANCE_KEY] = currentCacheIndex + } + + private fun isUserLoggedInFlow(): Flow { + return combine( + authenticationService.isLoggedIn(), + loginUserStory.loginFlowIsDone + ) { isLoggedIn, loginFlowIsDone -> + isLoggedIn && loginFlowIsDone + } + .distinctUntilChanged() + } + + private fun Flow.toIndexFlow(initialValue: Int): Flow = flow { + var index = initialValue + emit(initialValue) + collect { + emit(++index) + } + } + + private fun SavedStateMap?.getCacheIndexOrDefault(): Int { + return this?.get(SAVE_INSTANCE_KEY) as? Int ?: 0 + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt index 0ef0e02558..24c67a3824 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt @@ -27,7 +27,6 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.MatrixClient -import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import javax.inject.Inject diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClientProvider.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClientProvider.kt new file mode 100644 index 0000000000..44d1a1d1a6 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClientProvider.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.matrix.api + +import io.element.android.libraries.matrix.api.core.SessionId + +interface MatrixClientProvider { + /** + * Can be used to get or restore a MatrixClient with the given [SessionId]. + * If a [MatrixClient] is already in memory, it'll return it. Otherwise it'll try to restore one. + * Most of the time you want to use injected constructor instead of retrieving a MatrixClient with this provider. + */ + suspend fun getOrRestore(sessionId: SessionId): Result +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/di/MatrixClientsHolder.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/di/MatrixClientsHolder.kt deleted file mode 100644 index a6db7a1c9e..0000000000 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/di/MatrixClientsHolder.kt +++ /dev/null @@ -1,106 +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.ui.di - -import com.bumble.appyx.core.state.MutableSavedStateMap -import com.bumble.appyx.core.state.SavedStateMap -import io.element.android.libraries.di.AppScope -import io.element.android.libraries.di.SingleIn -import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.matrix.api.auth.AuthenticationException -import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService -import io.element.android.libraries.matrix.api.core.SessionId -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import timber.log.Timber -import java.util.concurrent.ConcurrentHashMap -import javax.inject.Inject -import kotlin.jvm.Throws - -private const val SAVE_INSTANCE_KEY = "io.element.android.x.di.MatrixClientsHolder.SaveInstanceKey" - -@SingleIn(AppScope::class) -class MatrixClientsHolder @Inject constructor(private val authenticationService: MatrixAuthenticationService) { - - private val sessionIdsToMatrixClient = ConcurrentHashMap() - private val restoreMutex = Mutex() - - private fun add(matrixClient: MatrixClient) { - sessionIdsToMatrixClient[matrixClient.sessionId] = matrixClient - } - - fun removeAll() { - sessionIdsToMatrixClient.clear() - } - - fun remove(sessionId: SessionId) { - sessionIdsToMatrixClient.remove(sessionId) - } - - fun isEmpty(): Boolean = sessionIdsToMatrixClient.isEmpty() - - fun knowSession(sessionId: SessionId): Boolean = sessionIdsToMatrixClient.containsKey(sessionId) - - fun getOrNull(sessionId: SessionId): MatrixClient? { - return sessionIdsToMatrixClient[sessionId] - } - - @Throws(AuthenticationException::class) - suspend fun requireSession(sessionId: SessionId): MatrixClient { - return restoreMutex.withLock { - when (val matrixClient = sessionIdsToMatrixClient[sessionId]) { - null -> restore(sessionId).getOrThrow() - else -> matrixClient - } - } - } - - private suspend fun restore(sessionId: SessionId): Result { - Timber.d("Restore matrix session: $sessionId") - return authenticationService.restoreSession(sessionId) - .onSuccess { matrixClient -> - add(matrixClient) - } - .onFailure { - Timber.e("Fail to restore session") - } - } - - @Suppress("UNCHECKED_CAST") - fun restore(state: SavedStateMap?) { - Timber.d("Restore state") - if (state == null || sessionIdsToMatrixClient.isNotEmpty()) return Unit.also { - Timber.w("Restore with non-empty map") - } - val sessionIds = state[SAVE_INSTANCE_KEY] as? Array - Timber.d("Restore matrix session keys = ${sessionIds?.map { it.value }}") - if (sessionIds.isNullOrEmpty()) return - // Not ideal but should only happens in case of process recreation. This ensure we restore all the active sessions before restoring the node graphs. - runBlocking { - sessionIds.forEach { sessionId -> - restore(sessionId) - } - } - } - - fun save(state: MutableSavedStateMap) { - val sessionKeys = sessionIdsToMatrixClient.keys.toTypedArray() - Timber.d("Save matrix session keys = ${sessionKeys.map { it.value }}") - state[SAVE_INSTANCE_KEY] = sessionKeys - } -} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt index dc484c29a3..8851b8eea8 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt @@ -16,7 +16,6 @@ package io.element.android.libraries.push.impl.notifications -import io.element.android.libraries.matrix.ui.di.MatrixClientsHolder import io.element.android.libraries.androidutils.throttler.FirstThrottler import io.element.android.libraries.core.cache.CircularCache import io.element.android.libraries.core.coroutine.CoroutineDispatchers @@ -24,12 +23,12 @@ import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.SingleIn -import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.push.api.notifications.NotificationDrawerManager import io.element.android.libraries.push.api.store.PushDataStore import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent @@ -60,8 +59,7 @@ class DefaultNotificationDrawerManager @Inject constructor( private val coroutineScope: CoroutineScope, private val dispatchers: CoroutineDispatchers, private val buildMeta: BuildMeta, - private val matrixAuthenticationService: MatrixAuthenticationService, - private val matrixClientsHolder: MatrixClientsHolder, + private val matrixClientProvider: MatrixClientProvider, ) : NotificationDrawerManager { /** * Lazily initializes the NotificationState as we rely on having a current session in order to fetch the persisted queue of events. @@ -257,8 +255,7 @@ class DefaultNotificationDrawerManager @Inject constructor( val currentUser = tryOrNull( onError = { Timber.e(it, "Unable to retrieve info for user ${sessionId.value}") }, operation = { - val client = matrixClientsHolder.requireSession(sessionId) - + val client = matrixClientProvider.getOrRestore(sessionId).getOrThrow() // myUserDisplayName cannot be empty else NotificationCompat.MessagingStyle() will crash val myUserDisplayName = client.loadUserDisplayName().getOrNull() ?: sessionId.value val userAvatarUrl = client.loadUserAvatarURLString().getOrNull() diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt index ad55bddb54..dbdddf1ac9 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt @@ -16,10 +16,8 @@ package io.element.android.libraries.push.impl.notifications -import io.element.android.libraries.matrix.ui.di.MatrixClientsHolder import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.meta.BuildMeta -import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId @@ -36,6 +34,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessage import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType import io.element.android.libraries.matrix.api.timeline.item.event.UnknownMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType +import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.push.impl.R import io.element.android.libraries.push.impl.log.pushLoggerTag import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent @@ -60,26 +59,25 @@ class NotifiableEventResolver @Inject constructor( private val stringProvider: StringProvider, // private val noticeEventFormatter: NoticeEventFormatter, // private val displayableEventFormatter: DisplayableEventFormatter, - private val matrixAuthenticationService: MatrixAuthenticationService, private val buildMeta: BuildMeta, private val clock: SystemClock, - private val matrixClientsHolder: MatrixClientsHolder, + private val matrixClientProvider: MatrixClientProvider, ) { suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? { // Restore session - val client = matrixClientsHolder.requireSession(sessionId) + val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return null val notificationService = client.notificationService() val notificationData = notificationService.getNotification( - userId = sessionId, - roomId = roomId, - eventId = eventId, + userId = sessionId, + roomId = roomId, + eventId = eventId, // FIXME should be true in the future, but right now it's broken // (https://github.com/vector-im/element-x-android/issues/640#issuecomment-1612913658) - filterByPushRules = false, - ).onFailure { - Timber.tag(loggerTag.value).e(it, "Unable to resolve event: $eventId.") - }.getOrNull() + filterByPushRules = false, + ).onFailure { + Timber.tag(loggerTag.value).e(it, "Unable to resolve event: $eventId.") + }.getOrNull() // TODO this notificationData is not always valid at the moment, sometimes the Rust SDK can't fetch the matching event return notificationData?.asNotifiableEvent(sessionId) From 98131d3046cbea182ab9139cdbcee4113021a5b0 Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 17 Jul 2023 21:32:07 +0200 Subject: [PATCH 28/42] MatrixClientHolders: some more cleanup --- .../android/appnav/di/MatrixClientsHolder.kt | 9 ++--- .../android/appnav/root/RootNavState.kt | 10 +++++ .../appnav/root/RootNavStateFlowFactory.kt | 39 ++++++++++++------- 3 files changed, 37 insertions(+), 21 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt index ac0eb60763..3e36e7d692 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt @@ -22,9 +22,9 @@ import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.SingleIn import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.matrix.api.MatrixClientProvider import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -36,7 +36,7 @@ private const val SAVE_INSTANCE_KEY = "io.element.android.x.di.MatrixClientsHold @SingleIn(AppScope::class) @ContributesBinding(AppScope::class) -class MatrixClientsHolder @Inject constructor(private val authenticationService: MatrixAuthenticationService): MatrixClientProvider { +class MatrixClientsHolder @Inject constructor(private val authenticationService: MatrixAuthenticationService) : MatrixClientProvider { private val sessionIdsToMatrixClient = ConcurrentHashMap() private val restoreMutex = Mutex() @@ -49,15 +49,13 @@ class MatrixClientsHolder @Inject constructor(private val authenticationService: sessionIdsToMatrixClient.remove(sessionId) } - fun isEmpty(): Boolean = sessionIdsToMatrixClient.isEmpty() - fun getOrNull(sessionId: SessionId): MatrixClient? { return sessionIdsToMatrixClient[sessionId] } override suspend fun getOrRestore(sessionId: SessionId): Result { return restoreMutex.withLock { - when (val matrixClient = sessionIdsToMatrixClient[sessionId]) { + when (val matrixClient = getOrNull(sessionId)) { null -> restore(sessionId) else -> Result.success(matrixClient) } @@ -97,5 +95,4 @@ class MatrixClientsHolder @Inject constructor(private val authenticationService: Timber.e("Fail to restore session") } } - } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavState.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavState.kt index 55edf96aee..ed3ac15972 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavState.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavState.kt @@ -16,7 +16,17 @@ package io.element.android.appnav.root +/** + * [RootNavState] produced by [RootNavStateFlowFactory]. + */ data class RootNavState( + /** + * This value is incremented when a clear cache is done. + * Can be useful to track to force ui state to re-render + */ val cacheIndex: Int, + /** + * true if we are currently loggedIn. + */ val isLoggedIn: Boolean ) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt index 84f50a835e..0e8d93b0c9 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt @@ -31,6 +31,10 @@ import javax.inject.Inject private const val SAVE_INSTANCE_KEY = "io.element.android.x.RootNavStateFlowFactory.SAVE_INSTANCE_KEY" +/** + * This class is responsible for creating a flow of [RootNavState]. + * It gathers data from multiple datasource and creates a unique one. + */ class RootNavStateFlowFactory @Inject constructor( private val authenticationService: MatrixAuthenticationService, private val cacheService: CacheService, @@ -41,11 +45,24 @@ class RootNavStateFlowFactory @Inject constructor( private var currentCacheIndex = 0 fun create(savedStateMap: SavedStateMap?): Flow { - /** - * A flow of integer, where each time a clear cache is done, we have a new incremented value. - */ + return combine( + cacheIndexFlow(savedStateMap), + isUserLoggedInFlow(), + ) { cacheIndex, isLoggedIn -> + RootNavState(cacheIndex = cacheIndex, isLoggedIn = isLoggedIn) + } + } + + fun saveIntoSavedState(stateMap: MutableSavedStateMap) { + stateMap[SAVE_INSTANCE_KEY] = currentCacheIndex + } + + /** + * @return a flow of integer, where each time a clear cache is done, we have a new incremented value. + */ + private fun cacheIndexFlow(savedStateMap: SavedStateMap?): Flow { val initialCacheIndex = savedStateMap.getCacheIndexOrDefault() - val cacheIndexFlow = cacheService.clearedCacheEventFlow + return cacheService.clearedCacheEventFlow .onEach { sessionId -> matrixClientsHolder.remove(sessionId) } @@ -53,17 +70,6 @@ class RootNavStateFlowFactory @Inject constructor( .onEach { cacheIndex -> currentCacheIndex = cacheIndex } - - return combine( - cacheIndexFlow, - isUserLoggedInFlow(), - ) { navId, isLoggedIn -> - RootNavState(navId, isLoggedIn) - } - } - - fun saveIntoSavedState(stateMap: MutableSavedStateMap) { - stateMap[SAVE_INSTANCE_KEY] = currentCacheIndex } private fun isUserLoggedInFlow(): Flow { @@ -76,6 +82,9 @@ class RootNavStateFlowFactory @Inject constructor( .distinctUntilChanged() } + /** + * @return a flow of integer that increments the value by one each time a new element is emitted upstream. + */ private fun Flow.toIndexFlow(initialValue: Int): Flow = flow { var index = initialValue emit(initialValue) From ddf432bb7263083519709d60e003708ab343ce97 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 17 Jul 2023 22:03:21 +0200 Subject: [PATCH 29/42] Change return type (mostly for clarity) --- .../libraries/matrix/impl/auth/AuthenticationException.kt | 2 +- .../android/libraries/matrix/impl/exception/ClientException.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt index 26a9fba38f..f0feb2857d 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt @@ -19,7 +19,7 @@ package io.element.android.libraries.matrix.impl.auth import io.element.android.libraries.matrix.api.auth.AuthenticationException import org.matrix.rustcomponents.sdk.AuthenticationException as RustAuthenticationException -fun Throwable.mapAuthenticationException(): Throwable { +fun Throwable.mapAuthenticationException(): AuthenticationException { return when (this) { is RustAuthenticationException.ClientMissing -> AuthenticationException.ClientMissing(this.message!!) is RustAuthenticationException.Generic -> AuthenticationException.Generic(this.message!!) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/exception/ClientException.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/exception/ClientException.kt index a72755d129..6efca88f9b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/exception/ClientException.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/exception/ClientException.kt @@ -19,7 +19,7 @@ package io.element.android.libraries.matrix.impl.exception import io.element.android.libraries.matrix.api.exception.ClientException import org.matrix.rustcomponents.sdk.ClientException as RustClientException -fun Throwable.mapClientException(): Throwable { +fun Throwable.mapClientException(): ClientException { return when (this) { is RustClientException.Generic -> ClientException.Generic(msg) else -> ClientException.Other(message ?: "Unknown error") From 95d3a7b1aeb0492531d5bb888b0617866cab4d2b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 17 Jul 2023 22:17:04 +0200 Subject: [PATCH 30/42] Ignore RootNavState regarding koverage. --- build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/build.gradle.kts b/build.gradle.kts index f8eda34bd8..3348a47c55 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -247,6 +247,7 @@ koverMerged { target = kotlinx.kover.api.VerificationTarget.CLASS overrideClassFilter { includes += "*State" + excludes += "io.element.android.appnav.root.RootNavState*" excludes += "io.element.android.libraries.matrix.api.timeline.item.event.OtherState$*" excludes += "io.element.android.libraries.matrix.api.timeline.item.event.EventSendState$*" excludes += "io.element.android.libraries.matrix.api.room.RoomMembershipState*" From ae3c0c7e329e6e6d97bc33f0fdbd89f30438a2fd Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 17 Jul 2023 23:27:18 +0200 Subject: [PATCH 31/42] RoomFlowNode: use newRoot instead of safeRoot as in this case it can create a race condition where we end up not switching node --- .../kotlin/io/element/android/appnav/room/RoomFlowNode.kt | 6 +++--- .../android/libraries/matrix/impl/RustMatrixClient.kt | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt index a03b6d61a2..20ec9f48b4 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt @@ -32,11 +32,11 @@ import com.bumble.appyx.core.node.node import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.plugins import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.newRoot import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode import io.element.android.appnav.NodeLifecycleCallback -import io.element.android.appnav.safeRoot import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.libraries.architecture.BackstackNode @@ -92,9 +92,9 @@ class RoomFlowNode @AssistedInject constructor( .distinctUntilChanged() .onEach { isLoaded -> if (isLoaded) { - backstack.safeRoot(NavTarget.Loaded) + backstack.newRoot(NavTarget.Loaded) } else { - backstack.safeRoot(NavTarget.Loading) + backstack.newRoot(NavTarget.Loading) } }.launchIn(lifecycleScope) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index d7195ebf11..a467a161e0 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -165,8 +165,10 @@ class RustMatrixClient constructor( val cachedRoomListItem = roomListService.roomOrNull(roomId.value) val fullRoom = cachedRoomListItem?.fullRoom() if (cachedRoomListItem == null || fullRoom == null) { + Timber.d("No room cached for $roomId") null } else { + Timber.d("Found room cached for $roomId") Pair(cachedRoomListItem, fullRoom) } } From 8a22ce0cd4509045b18a5987e3f0c90e0dd5d5c6 Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 17 Jul 2023 23:47:00 +0200 Subject: [PATCH 32/42] Timeline: changes after pr review --- .../impl/timeline/TimelinePresenter.kt | 47 +++++++++++++------ 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 353d94e390..488b6093cd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -25,7 +25,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.libraries.architecture.Presenter @@ -62,8 +61,8 @@ class TimelinePresenter @Inject constructor( mutableStateOf(null) } - var lastReadReceiptIndex by rememberSaveable { mutableStateOf(Int.MAX_VALUE) } - var lastReadReceiptId by rememberSaveable { mutableStateOf(null) } + val lastReadReceiptIndex = rememberSaveable { mutableStateOf(Int.MAX_VALUE) } + val lastReadReceiptId = rememberSaveable { mutableStateOf(null) } val timelineItems by timelineItemsFactory.collectItemsAsState() val paginationState by timeline.paginationState.collectAsState() @@ -73,16 +72,6 @@ class TimelinePresenter @Inject constructor( val prevMostRecentItemId = rememberSaveable { mutableStateOf(null) } val hasNewItems = remember { mutableStateOf(false) } - fun CoroutineScope.sendReadReceiptIfNeeded(firstVisibleIndex: Int) = launch(dispatchers.computation) { - // Get last valid EventId seen by the user, as the first index might refer to a Virtual item - val eventId = getLastEventIdBeforeOrAt(firstVisibleIndex, timelineItems) - if (eventId != null && firstVisibleIndex <= lastReadReceiptIndex && eventId != lastReadReceiptId) { - lastReadReceiptIndex = firstVisibleIndex - lastReadReceiptId = eventId - timeline.sendReadReceipt(eventId) - } - } - fun handleEvents(event: TimelineEvents) { when (event) { TimelineEvents.LoadMore -> localScope.paginateBackwards() @@ -91,7 +80,12 @@ class TimelinePresenter @Inject constructor( if (event.firstIndex == 0) { hasNewItems.value = false } - appScope.sendReadReceiptIfNeeded(event.firstIndex) + appScope.sendReadReceiptIfNeeded( + firstVisibleIndex = event.firstIndex, + timelineItems = timelineItems, + lastReadReceiptIndex = lastReadReceiptIndex, + lastReadReceiptId = lastReadReceiptId + ) } } } @@ -122,6 +116,11 @@ class TimelinePresenter @Inject constructor( ) } + /** + * This method compute the hasNewItem state passed as a [MutableState] each time the timeline items size changes. + * Basically, if we got new timeline event from sync or local, either from us or another user, we update the state so we tell we have new items. + * The state never goes back to false from this method, but need to be reset from somewhere else. + */ private suspend fun computeHasNewItems( timelineItems: ImmutableList, prevMostRecentItemId: MutableState, @@ -130,13 +129,31 @@ class TimelinePresenter @Inject constructor( val newMostRecentItem = timelineItems.firstOrNull() val prevMostRecentItemIdValue = prevMostRecentItemId.value val newMostRecentItemId = newMostRecentItem?.identifier() - hasNewItemsState.value = prevMostRecentItemIdValue != null && + val hasNewItems = prevMostRecentItemIdValue != null && newMostRecentItem is TimelineItem.Event && newMostRecentItem.origin != TimelineItemEventOrigin.PAGINATION && newMostRecentItemId != prevMostRecentItemIdValue + if (hasNewItems) { + hasNewItemsState.value = true + } prevMostRecentItemId.value = newMostRecentItemId } + private fun CoroutineScope.sendReadReceiptIfNeeded( + firstVisibleIndex: Int, + timelineItems: ImmutableList, + lastReadReceiptIndex: MutableState, + lastReadReceiptId: MutableState, + ) = launch(dispatchers.computation) { + // Get last valid EventId seen by the user, as the first index might refer to a Virtual item + val eventId = getLastEventIdBeforeOrAt(firstVisibleIndex, timelineItems) + if (eventId != null && firstVisibleIndex <= lastReadReceiptIndex.value && eventId != lastReadReceiptId.value) { + lastReadReceiptIndex.value = firstVisibleIndex + lastReadReceiptId.value = eventId + timeline.sendReadReceipt(eventId) + } + } + private fun getLastEventIdBeforeOrAt(index: Int, items: ImmutableList): EventId? { for (item in items.subList(index, items.count())) { if (item is TimelineItem.Event) { From 12a4882cc8f019adb95616767b87dd08fc0789e9 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 18 Jul 2023 00:19:12 +0200 Subject: [PATCH 33/42] Add `ftue` to the dict. --- .idea/dictionaries/shared.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/.idea/dictionaries/shared.xml b/.idea/dictionaries/shared.xml index abe4b190df..aafe02a2c8 100644 --- a/.idea/dictionaries/shared.xml +++ b/.idea/dictionaries/shared.xml @@ -2,6 +2,7 @@ backstack + ftue homeserver kover measurables From d6980025caba4151931fe4242987102358f77e43 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 18 Jul 2023 00:18:48 +0200 Subject: [PATCH 34/42] When clearing cache, also reset some data store in prefs/datastore. --- .../features/analytics/test/FakeAnalyticsService.kt | 4 ++++ .../element/android/features/ftue/api/state/FtueState.kt | 2 ++ .../android/features/ftue/impl/state/DefaultFtueState.kt | 5 +++++ .../ftue/impl/welcome/state/AndroidWelcomeScreenState.kt | 9 ++++++++- .../ftue/impl/welcome/state/WelcomeScreenState.kt | 1 + .../features/ftue/impl/welcome/state/FakeWelcomeState.kt | 4 ++++ features/preferences/impl/build.gradle.kts | 1 + .../features/preferences/impl/tasks/ClearCacheUseCase.kt | 4 ++++ .../android/services/analytics/api/AnalyticsService.kt | 5 +++++ .../services/analytics/impl/DefaultAnalyticsService.kt | 4 ++++ 10 files changed, 38 insertions(+), 1 deletion(-) diff --git a/features/analytics/test/src/main/kotlin/io/element/android/features/analytics/test/FakeAnalyticsService.kt b/features/analytics/test/src/main/kotlin/io/element/android/features/analytics/test/FakeAnalyticsService.kt index 7ff0f50d9c..6e84c58d2a 100644 --- a/features/analytics/test/src/main/kotlin/io/element/android/features/analytics/test/FakeAnalyticsService.kt +++ b/features/analytics/test/src/main/kotlin/io/element/android/features/analytics/test/FakeAnalyticsService.kt @@ -67,4 +67,8 @@ class FakeAnalyticsService( override fun trackError(throwable: Throwable) { } + + override suspend fun reset() { + didAskUserConsentFlow.value = false + } } diff --git a/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/state/FtueState.kt b/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/state/FtueState.kt index 2c19d4e3a7..cd172669cc 100644 --- a/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/state/FtueState.kt +++ b/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/state/FtueState.kt @@ -20,4 +20,6 @@ import kotlinx.coroutines.flow.StateFlow interface FtueState { val shouldDisplayFlow: StateFlow + + suspend fun reset() } diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueState.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueState.kt index 39b100808f..52c8d90254 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueState.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueState.kt @@ -39,6 +39,11 @@ class DefaultFtueState @Inject constructor( override val shouldDisplayFlow = MutableStateFlow(isAnyStepIncomplete()) + override suspend fun reset() { + welcomeScreenState.reset() + analyticsService.reset() + } + init { analyticsService.didAskUserConsent() .onEach { updateState() } diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/AndroidWelcomeScreenState.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/AndroidWelcomeScreenState.kt index c482b4e744..6dbef47285 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/AndroidWelcomeScreenState.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/AndroidWelcomeScreenState.kt @@ -17,6 +17,7 @@ package io.element.android.features.ftue.impl.welcome.state import android.content.SharedPreferences +import androidx.core.content.edit import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.DefaultPreferences @@ -27,7 +28,7 @@ import javax.inject.Inject @SingleIn(AppScope::class) class AndroidWelcomeScreenState @Inject constructor( @DefaultPreferences private val sharedPreferences: SharedPreferences, -): WelcomeScreenState { +) : WelcomeScreenState { companion object { private const val IS_WELCOME_SCREEN_SHOWN = "is_welcome_screen_shown" @@ -40,4 +41,10 @@ class AndroidWelcomeScreenState @Inject constructor( override fun setWelcomeScreenShown() { sharedPreferences.edit().putBoolean(IS_WELCOME_SCREEN_SHOWN, true).apply() } + + override fun reset() { + sharedPreferences.edit { + remove(IS_WELCOME_SCREEN_SHOWN) + } + } } diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/WelcomeScreenState.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/WelcomeScreenState.kt index 0e5f79d7c1..d2be17fcbb 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/WelcomeScreenState.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/WelcomeScreenState.kt @@ -19,4 +19,5 @@ package io.element.android.features.ftue.impl.welcome.state interface WelcomeScreenState { fun isWelcomeScreenNeeded(): Boolean fun setWelcomeScreenShown() + fun reset() } diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/FakeWelcomeState.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/FakeWelcomeState.kt index 198d79115a..e38d49db1c 100644 --- a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/FakeWelcomeState.kt +++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/FakeWelcomeState.kt @@ -27,4 +27,8 @@ class FakeWelcomeState : WelcomeScreenState { override fun setWelcomeScreenShown() { isWelcomeScreenNeeded = false } + + override fun reset() { + isWelcomeScreenNeeded = true + } } diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts index 682757d802..f183f7f1fa 100644 --- a/features/preferences/impl/build.gradle.kts +++ b/features/preferences/impl/build.gradle.kts @@ -44,6 +44,7 @@ dependencies { implementation(projects.libraries.uiStrings) implementation(projects.features.rageshake.api) implementation(projects.features.analytics.api) + implementation(projects.features.ftue.api) implementation(projects.libraries.matrixui) implementation(projects.features.logout.api) implementation(projects.services.toolbox.api) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt index 24c67a3824..07ca0716e9 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt @@ -22,6 +22,7 @@ import android.content.Context import coil.Coil import coil.annotation.ExperimentalCoilApi import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.ftue.api.state.FtueState import io.element.android.features.preferences.impl.DefaultCacheService import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.di.ApplicationContext @@ -43,6 +44,7 @@ class DefaultClearCacheUseCase @Inject constructor( private val coroutineDispatchers: CoroutineDispatchers, private val defaultCacheIndexProvider: DefaultCacheService, private val okHttpClient: Provider, + private val ftueState: FtueState, ) : ClearCacheUseCase { override suspend fun invoke() = withContext(coroutineDispatchers.io) { // Clear Matrix cache @@ -56,6 +58,8 @@ class DefaultClearCacheUseCase @Inject constructor( okHttpClient.get().cache?.delete() // Clear app cache context.cacheDir.deleteRecursively() + // Clear some settings + ftueState.reset() // Ensure the app is restarted defaultCacheIndexProvider.onClearedCache(matrixClient.sessionId) } diff --git a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsService.kt b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsService.kt index bf06542adb..9c6fb2d522 100644 --- a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsService.kt +++ b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsService.kt @@ -58,4 +58,9 @@ interface AnalyticsService: AnalyticsTracker, ErrorTracker { * To be called when a session is destroyed. */ suspend fun onSignOut() + + /** + * Reset the analytics service (will ask for user consent again). + */ + suspend fun reset() } diff --git a/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsService.kt b/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsService.kt index 3fe45e53b7..5639f954ac 100644 --- a/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsService.kt +++ b/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsService.kt @@ -78,6 +78,10 @@ class DefaultAnalyticsService @Inject constructor( analyticsStore.setDidAskUserConsent() } + override suspend fun reset() { + analyticsStore.setDidAskUserConsent(false) + } + override fun getAnalyticsId(): Flow { return analyticsStore.analyticsIdFlow } From baf06d8615deec5a78fadc1eb42e2ad22523684a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 18 Jul 2023 08:29:06 +0200 Subject: [PATCH 35/42] Fix `TimelinePresenterTests` --- .../features/messages/timeline/TimelinePresenterTest.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt index ad6e41e483..c1d414c633 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt @@ -102,6 +102,8 @@ class TimelinePresenterTest { }.test { assertThat(timeline.sendReadReceiptCount).isEqualTo(0) val initialState = awaitItem() + // Wait for timeline items to be populated + skipItems(1) awaitWithLatch { latch -> timeline.sendReadReceiptLatch = latch initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0)) @@ -124,6 +126,8 @@ class TimelinePresenterTest { }.test { assertThat(timeline.sendReadReceiptCount).isEqualTo(0) val initialState = awaitItem() + // Wait for timeline items to be populated + skipItems(1) awaitWithLatch { latch -> timeline.sendReadReceiptLatch = latch initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1)) @@ -146,6 +150,8 @@ class TimelinePresenterTest { }.test { assertThat(timeline.sendReadReceiptCount).isEqualTo(0) val initialState = awaitItem() + // Wait for timeline items to be populated + skipItems(1) awaitWithLatch { latch -> timeline.sendReadReceiptLatch = latch initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0)) From 9a76adcd5e2d2c13d1e638a1590d359570eb519c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 18 Jul 2023 02:00:31 +0200 Subject: [PATCH 36/42] Fix text color --- .../android/features/ftue/impl/welcome/WelcomeView.kt | 2 ++ .../designsystem/atomic/molecules/InfoListOrganism.kt | 9 ++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeView.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeView.kt index ccb55494b8..ad040a25e8 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeView.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeView.kt @@ -76,12 +76,14 @@ fun WelcomeView( modifier = Modifier.testTag(TestTags.welcomeScreenTitle), text = stringResource(R.string.screen_welcome_title, applicationName), style = ElementTheme.typography.fontHeadingLgBold, + color = ElementTheme.colors.textPrimary, textAlign = TextAlign.Center, ) Spacer(modifier = Modifier.height(8.dp)) Text( text = stringResource(R.string.screen_welcome_subtitle), style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textPrimary, textAlign = TextAlign.Center, ) Spacer(modifier = Modifier.height(40.dp)) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/InfoListOrganism.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/InfoListOrganism.kt index 60c61d99fc..1bc82eb3b2 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/InfoListOrganism.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/InfoListOrganism.kt @@ -31,6 +31,7 @@ import io.element.android.libraries.designsystem.atomic.atoms.InfoListItemMolecu import io.element.android.libraries.designsystem.atomic.atoms.InfoListItemPosition import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.theme.ElementTheme import kotlinx.collections.immutable.ImmutableList @Composable @@ -54,7 +55,13 @@ fun InfoListOrganism( else -> InfoListItemPosition.Middle } InfoListItemMolecule( - message = { Text(item.message, style = textStyle) }, + message = { + Text( + text = item.message, + style = textStyle, + color = ElementTheme.colors.textPrimary, + ) + }, icon = { if (item.iconId != null) { Icon(resourceId = item.iconId, contentDescription = null, tint = iconTint) From eb2bec147e7063548e1073e3d0028d45c65bbe97 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 18 Jul 2023 02:05:12 +0200 Subject: [PATCH 37/42] Use correct font (from Figma) --- .../element/android/features/ftue/impl/welcome/WelcomeView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeView.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeView.kt index ad040a25e8..7397e5ecc5 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeView.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeView.kt @@ -75,7 +75,7 @@ fun WelcomeView( Text( modifier = Modifier.testTag(TestTags.welcomeScreenTitle), text = stringResource(R.string.screen_welcome_title, applicationName), - style = ElementTheme.typography.fontHeadingLgBold, + style = ElementTheme.typography.fontHeadingMdBold, color = ElementTheme.colors.textPrimary, textAlign = TextAlign.Center, ) From 0fd9ba56026f5e011f8f35d9ef5ce31e70731d9c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 18 Jul 2023 02:11:15 +0200 Subject: [PATCH 38/42] Format file --- .../libraries/designsystem/atomic/atoms/ElementLogoAtom.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/ElementLogoAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/ElementLogoAtom.kt index 19a1798f74..1ee4826fc0 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/ElementLogoAtom.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/ElementLogoAtom.kt @@ -62,7 +62,7 @@ fun ElementLogoAtom( ElementLogoAtomSize.Large -> 110.dp ElementLogoAtomSize.Medium -> 83.5.dp } - val cornerRadius = when(size) { + val cornerRadius = when (size) { ElementLogoAtomSize.Large -> 44.dp ElementLogoAtomSize.Medium -> 33.dp } @@ -126,7 +126,8 @@ internal fun ElementLogoAtomPreview() { Box( Modifier .size(170.dp) - .background(ElementTheme.colors.bgSubtlePrimary)) + .background(ElementTheme.colors.bgSubtlePrimary) + ) ElementLogoAtom(ElementLogoAtomSize.Large) } } From d3f1d122ed09716b24cd7bd38c930a2ed491bcde Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 18 Jul 2023 02:13:23 +0200 Subject: [PATCH 39/42] Fix preview. --- .../designsystem/atomic/atoms/ElementLogoAtom.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/ElementLogoAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/ElementLogoAtom.kt index 1ee4826fc0..93b489e17d 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/ElementLogoAtom.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/ElementLogoAtom.kt @@ -125,10 +125,12 @@ internal fun ElementLogoAtomPreview() { ElementPreview { Box( Modifier - .size(170.dp) - .background(ElementTheme.colors.bgSubtlePrimary) - ) - ElementLogoAtom(ElementLogoAtomSize.Large) + .size(180.dp) + .background(ElementTheme.colors.bgSubtlePrimary), + contentAlignment = Alignment.Center + ) { + ElementLogoAtom(ElementLogoAtomSize.Large) + } } } From efccf36acd3700becdb8d439b42c49609e0fbc9c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 18 Jul 2023 02:18:07 +0200 Subject: [PATCH 40/42] Use the modifier parameter. --- .../android/features/analytics/impl/AnalyticsOptInView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt index ba6d84ae74..a27e6e7399 100644 --- a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt +++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt @@ -135,7 +135,7 @@ private fun AnalyticsOptInHeader( @Composable private fun CheckIcon(modifier: Modifier = Modifier) { Icon( - modifier = Modifier + modifier = modifier .size(20.dp) .background(color = MaterialTheme.colorScheme.background, shape = CircleShape) .padding(2.dp), From a69b5a19b4fb6698d0d0c661c61104883cee8bb6 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Tue, 18 Jul 2023 00:30:51 +0000 Subject: [PATCH 41/42] Update screenshots --- ...ultGroup_WelcomeViewPreview-D-0_1_null,NEXUS_5,1.0,en].png | 4 ++-- ...ultGroup_WelcomeViewPreview-N-0_2_null,NEXUS_5,1.0,en].png | 4 ++-- ...tGroup_ElementLogoAtomPreview-D_0_null,NEXUS_5,1.0,en].png | 4 ++-- ...tGroup_ElementLogoAtomPreview-N_1_null,NEXUS_5,1.0,en].png | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.ftue.impl.welcome_null_DefaultGroup_WelcomeViewPreview-D-0_1_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.ftue.impl.welcome_null_DefaultGroup_WelcomeViewPreview-D-0_1_null,NEXUS_5,1.0,en].png index d0e4757049..f300f92921 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.ftue.impl.welcome_null_DefaultGroup_WelcomeViewPreview-D-0_1_null,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.ftue.impl.welcome_null_DefaultGroup_WelcomeViewPreview-D-0_1_null,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:76a68f2fc93894d6f9d9caea02546766c55664c0e53ba9506c6c32df058f5823 -size 303608 +oid sha256:16de62092834bf803c8165e974f45e14ccfc0128a3e74295a58eef965abc10c5 +size 301336 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.ftue.impl.welcome_null_DefaultGroup_WelcomeViewPreview-N-0_2_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.ftue.impl.welcome_null_DefaultGroup_WelcomeViewPreview-N-0_2_null,NEXUS_5,1.0,en].png index 68ee3f3801..7465768560 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.ftue.impl.welcome_null_DefaultGroup_WelcomeViewPreview-N-0_2_null,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.ftue.impl.welcome_null_DefaultGroup_WelcomeViewPreview-N-0_2_null,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f24bb3e40dd8c02037bd9d4523726ec0a0b1a283d23a8ca143973b0e9ee673c6 -size 408318 +oid sha256:6838e81cc5f2755ff76de7254e2c8bb445b76662d7ba9b4c83443b2c2ed03029 +size 406044 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_ElementLogoAtomPreview-D_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_ElementLogoAtomPreview-D_0_null,NEXUS_5,1.0,en].png index d903b752b0..4a98068e90 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_ElementLogoAtomPreview-D_0_null,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_ElementLogoAtomPreview-D_0_null,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2947531c19a0ac9a7e35c3f2a394f6eb805427e1ad296d22b7d8b5cbb2428e07 -size 20947 +oid sha256:2b19b24fc94f200579827f66557a183842d5234881ed84fe2b8b74d935b90666 +size 22697 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_ElementLogoAtomPreview-N_1_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_ElementLogoAtomPreview-N_1_null,NEXUS_5,1.0,en].png index bd9fca6c0f..02c167a01b 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_ElementLogoAtomPreview-N_1_null,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_ElementLogoAtomPreview-N_1_null,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6f88eb992060d5b41ce3200bdc48d4fe6accaeda857d1ca08cb65ed8235798f7 -size 20266 +oid sha256:e35ea20cabe37c05a594bce1b6b4a3c2175470408c18db25874ad5db088f733f +size 21219 From e4a7fcc60cf8ef794c213214761e502116543375 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 18 Jul 2023 12:25:18 +0200 Subject: [PATCH 42/42] Update gradle/gradle-build-action action to v2.6.1 (#893) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 2 +- .github/workflows/nightlyReports.yml | 2 +- .github/workflows/quality.yml | 2 +- .github/workflows/recordScreenshots.yml | 2 +- .github/workflows/tests.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 59719a6de4..90335268a7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,7 +34,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '17' - name: Configure gradle - uses: gradle/gradle-build-action@v2.6.0 + uses: gradle/gradle-build-action@v2.6.1 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Assemble debug APK diff --git a/.github/workflows/nightlyReports.yml b/.github/workflows/nightlyReports.yml index 89c992e2c9..ce7b763ef1 100644 --- a/.github/workflows/nightlyReports.yml +++ b/.github/workflows/nightlyReports.yml @@ -62,7 +62,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '17' - name: Configure gradle - uses: gradle/gradle-build-action@v2.6.0 + uses: gradle/gradle-build-action@v2.6.1 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Dependency analysis diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 9dfe7dc06b..94b8b7ff4e 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -39,7 +39,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '17' - name: Configure gradle - uses: gradle/gradle-build-action@v2.6.0 + uses: gradle/gradle-build-action@v2.6.1 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Run code quality check suite diff --git a/.github/workflows/recordScreenshots.yml b/.github/workflows/recordScreenshots.yml index a84ff0d9cf..d088b3ad94 100644 --- a/.github/workflows/recordScreenshots.yml +++ b/.github/workflows/recordScreenshots.yml @@ -24,7 +24,7 @@ jobs: java-version: '17' # Add gradle cache, this should speed up the process - name: Configure gradle - uses: gradle/gradle-build-action@v2.6.0 + uses: gradle/gradle-build-action@v2.6.1 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Record screenshots diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e5a35da4c9..04fd393e25 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -33,7 +33,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '17' - name: Configure gradle - uses: gradle/gradle-build-action@v2.6.0 + uses: gradle/gradle-build-action@v2.6.1 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }}