From 771a4ecdd3d3da85c6bb3acdbec03924e64c8ae7 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 12 Jul 2023 18:15:17 +0200 Subject: [PATCH 01/15] 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 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 02/15] 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 03/15] 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 04/15] 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 05/15] 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 06/15] 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 07/15] 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 08/15] 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 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 09/15] 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 10/15] 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 a852465554e65860941922435b01c15dc8403861 Mon Sep 17 00:00:00 2001 From: Marco Romano Date: Mon, 17 Jul 2023 16:22:29 +0200 Subject: [PATCH 11/15] 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 12/15] 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 13/15] 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 14/15] 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 15/15] 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_.*" + ] } ] }